test/round3.test.mjs (46570B)
1 // Regression tests for the third round of tester reports, and for the engine notes of the same round. Each test
2 // replays the tester's input (real probe and server script outputs where the tester had them) against
3 // upstream.json and a frozen copy of distro.json (fixtures/distro-snapshot.json), so data refreshes do not move the
4 // verdicts. Groups: D (Debian and Ubuntu), R (RHEL family and Docker), C (crafted inputs), X (rendering and
5 // robustness), N (notes from the server script and probe work).
6 import { test } from 'node:test';
7 import assert from 'node:assert/strict';
8 import { readFileSync } from 'node:fs';
9 import { parseInput } from '../site/lib/parse.js';
10 import { judge, sapiLabel } from '../site/lib/judge.js';
11
12 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
13 const distro = JSON.parse(readFileSync(new URL('./fixtures/distro-snapshot.json', import.meta.url), 'utf8'));
14
15 // A probe block as the probe prints it (the fields in the probe's own order).
16 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`;
17 const serverLines = (lines) => ['tool|php2shell-server-check|1.0.0', ...lines].join('\n');
18 const server = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${serverLines(lines)}\n-----END PHP2SHELL SERVERCHECK-----\n`;
19 const run = (text, options = {}) => judge(parseInput(text, { lang: options.lang }), upstream, distro, options);
20 const both = (text, options = {}) => ['en', 'ko'].map((lang) => run(text, { ...options, lang }));
21 const reasons = (r) => r.reasons.join(' ');
22 const notes = (r) => r.notes.join(' ');
23 const actionText = (r) => r.actions.map((a) => `${a.text}\n${a.code || ''}`).join('\n');
24 const all = (r) => [...r.reasons, ...r.notes, actionText(r)].join('\n');
25 const codes = (r) => r.actions.map((a) => a.code || '').join('\n');
26 const CVES = ['CVE-2026-109618', 'CVE-2026-109619'];
27 const logged = (pkgs, n) => pkgs.flatMap((p) => CVES.map((c) => `changelog|${p}|${c}|${typeof n === 'number' ? n : n[c]}`));
28 const verdicts = (text, options) => both(text, options).map((r) => r.verdict);
29
30 // ── Real outputs from the tester's containers ───────────────────────
31
32 const PROBE_BASE = { open_basedir: false, container: false, self_deleted: true, extensions: { session: true, opcache: true } };
33 const JAMMY_OS = { PRETTY_NAME: 'Ubuntu 22.04.5 LTS', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy', ID: 'ubuntu', ID_LIKE: 'debian' };
34 const NOBLE_OS = { PRETTY_NAME: 'Ubuntu 24.04.5 LTS', VERSION_ID: '24.04', VERSION_CODENAME: 'noble', ID: 'ubuntu', ID_LIKE: 'debian' };
35 const BOOKWORM_OS = { PRETTY_NAME: 'Debian GNU/Linux 12 (bookworm)', VERSION_ID: '12', VERSION_CODENAME: 'bookworm', ID: 'debian' };
36 const deb = (names, version, source) => names.map((name) => ({ name, version, source }));
37 const JAMMY_LINES = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', 'os|PRETTY_NAME|Ubuntu 22.04.5 LTS'];
38 const NOBLE_LINES = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble', 'os|PRETTY_NAME|Ubuntu 24.04.5 LTS'];
39 const BOOKWORM_LINES = ['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'os|VERSION_CODENAME|bookworm', 'os|PRETTY_NAME|Debian GNU/Linux 12 (bookworm)'];
40 const LOGS = ['webuser_proc|0', 'log|oom|0', 'log|crash|unknown', 'log|bigsession|0'];
41
42 const JAMMY_FIXED = '8.1.2-1ubuntu2.28';
43 const NOBLE_FIXED = '8.3.6-0ubuntu0.24.04.13';
44 const MOD81 = ['libapache2-mod-php8.1', 'php8.1-cli', 'php8.1-common'];
45 const NOBLE3 = ['php8.3-cli', 'php8.3-common', 'php8.3-fpm'];
46 const DEB82 = ['php8.2-cli', 'php8.2-common', 'php8.2-fpm'];
47
48 // Ubuntu 22.04, Apache mod_php, updated from the jammy GA build with no restart (p2s-pa-u22-stale-probe.txt).
49 const F1 = probe({ ...PROBE_BASE, php_version: '8.1.2', php_extra_version: '', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', server_software: 'Apache/2.4.52 (Ubuntu)', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, JAMMY_FIXED, 'php8.1') });
50 // Debian 12 php8.2-fpm running 8.2.35, downgraded on disk to 8.2.32 with no restart (p2s-pa-d12-downgrade-probe.txt).
51 const F2 = probe({ ...PROBE_BASE, php_version: '8.2.35', php_extra_version: '', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', server_software: 'nginx/1.22.1', os_release: BOOKWORM_OS, dpkg_packages: deb(DEB82, '8.2.32-1~deb12u1', 'php8.2') });
52 // Ubuntu 24.04 php8.3-fpm updated with no restart: the probe, and the server script output copied without its BEGIN line.
53 const F3_PROBE = probe({ ...PROBE_BASE, php_version: '8.3.6', php_extra_version: '', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', server_software: 'nginx/1.24.0', os_release: NOBLE_OS, dpkg_packages: deb(NOBLE3, NOBLE_FIXED, 'php8.3') });
54 const F3_LINES = [...NOBLE_LINES, ...NOBLE3.map((p) => `pkg|dpkg|${p}|${NOBLE_FIXED}|php8.3`), ...logged(NOBLE3, { 'CVE-2026-109618': 2, 'CVE-2026-109619': 1 }), 'proc|php-fpm8.3|1|stale', ...LOGS];
55 const F3_HEADLESS = `${serverLines(F3_LINES)}\n-----END PHP2SHELL SERVERCHECK-----\n`;
56 // Ubuntu 24.04 interrupted upgrade: php8.3-fpm half-configured at 8.3.6-0maysync1, so neither collector lists it.
57 const F6_PROBE = probe({ ...PROBE_BASE, extensions: { session: true, opcache: false }, php_version: '8.3.6', php_extra_version: '', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', server_software: 'nginx/1.24.0', os_release: NOBLE_OS, dpkg_packages: deb(['php8.3-cli', 'php8.3-common'], NOBLE_FIXED, 'php8.3') });
58 const F6_ROOT = server([...NOBLE_LINES, `pkg|dpkg|php8.3-cli|${NOBLE_FIXED}|php8.3`, `pkg|dpkg|php8.3-common|${NOBLE_FIXED}|php8.3`, ...logged(['php8.3-cli', 'php8.3-common'], { 'CVE-2026-109618': 2, 'CVE-2026-109619': 1 }), 'proc|php-fpm8.3|1|ok', ...LOGS]);
59 // Ubuntu 22.04: a probe while .28 ran, and a server script output after a downgrade to the GA build with no restart.
60 const F7_PROBE = probe({ ...PROBE_BASE, php_version: JAMMY_FIXED, php_extra_version: '-1ubuntu2.28', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', server_software: 'Apache/2.4.52 (Ubuntu)', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, JAMMY_FIXED, 'php8.1') });
61 const F7_SERVER = server([...JAMMY_LINES, ...MOD81.map((p) => `pkg|dpkg|${p}|8.1.2-1ubuntu2|php8.1`), ...logged(MOD81, 0), 'proc|apache2|1|stale', ...LOGS]);
62 // deb.sury.org PHP 8.1 (end of life upstream) whose 8.1.34-9 changelog records the backported fixes.
63 const SURY_DEB = '8.1.34-9+0~20261021.82+debian12~1.gbp4e430a';
64 const SURY_PPA = '8.1.34-9+ubuntu22.04.1+deb.sury.org+1';
65 const FPM81 = ['php8.1-cli', 'php8.1-common', 'php8.1-fpm'];
66 const F4_DEB_PROBE = probe({ ...PROBE_BASE, php_version: '8.1.34', php_extra_version: '', sapi: 'fpm-fcgi', config_path: '/etc/php/8.1/fpm', server_software: 'nginx/1.22.1', os_release: BOOKWORM_OS, dpkg_packages: deb(FPM81, SURY_DEB, 'php8.1') });
67 const F4_DEB = F4_DEB_PROBE + server([...BOOKWORM_LINES, ...FPM81.map((p) => `pkg|dpkg|${p}|${SURY_DEB}|php8.1`), ...logged(FPM81, 1), 'proc|php-fpm8.1|1|ok', 'webuser_proc|0']);
68 const F4_PPA_PROBE = probe({ ...PROBE_BASE, php_version: '8.1.34', php_extra_version: '', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', server_software: 'Apache/2.4.52 (Ubuntu)', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, SURY_PPA, 'php8.1') });
69 const F4_PPA = F4_PPA_PROBE + server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', ...MOD81.map((p) => `pkg|dpkg|${p}|${SURY_PPA}|php8.1`), ...logged(MOD81, 1), 'proc|apache2|1|ok', 'webuser_proc|0']);
70 // Debian 12 downgrade: the probe (8.2.35 running, 8.2.32 on disk) and the root server script of the same moment.
71 const F5_SERVER = server([...BOOKWORM_LINES, ...DEB82.map((p) => `pkg|dpkg|${p}|8.2.32-1~deb12u1|php8.2`), ...logged(DEB82, 0), 'proc|php-fpm8.2|1|stale', ...LOGS]);
72 const F5 = F2 + F5_SERVER;
73
74 // ── D: Debian and Ubuntu ────────────────────────────────────────────
75
76 test('D1 Ubuntu 22.04: a plain 8.1.2 next to the updated .28 package is the GA build loaded before the update', () => {
77 const header = 'HTTP/1.1 200 OK\nDate: Fri, 23 Oct 2026 06:00:00 GMT\nServer: Apache/2.4.52 (Ubuntu)\nContent-Type: text/html; charset=UTF-8\n';
78 for (const text of [F1, F1 + F1, `${header}\n${F1}`]) {
79 for (const r of both(text)) {
80 assert.equal(r.verdict, 'restart', text.slice(0, 40));
81 assert.doesNotMatch(reasons(r), /is the same for the old and the fixed build|이전 빌드와 수정된 빌드가 같으므로/);
82 }
83 assert.match(reasons(run(text)), /original build of the release reports the plain version/);
84 assert.ok(run(text).actions.some((a) => a.code === 'sudo systemctl restart apache2'));
85 }
86 // Positive controls: Ubuntu 24.04 really reports a plain 8.3.6 after the fix, and the GA package itself is Vulnerable.
87 assert.equal(run(F3_PROBE).verdict, 'patched');
88 assert.equal(run(probe({ ...PROBE_BASE, php_version: '8.1.2', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, '8.1.2-1ubuntu2', 'php8.1') })).verdict, 'vulnerable');
89 });
90
91 test('D2 Debian 12: PHP-FPM still running 8.2.35 after a downgrade to 8.2.32 is Vulnerable, with no reload before the reinstall', () => {
92 for (const text of [F2, F2 + F2]) {
93 for (const r of both(text)) {
94 assert.equal(r.verdict, 'vulnerable');
95 assert.match(reasons(r), /8\.2\.32-1~deb12u1/);
96 assert.doesNotMatch(codes(r), /reload/, 'no reload while the vulnerable build is on disk');
97 }
98 }
99 const r = run(F2);
100 assert.match(reasons(r), /next restart of the service loads the vulnerable build/);
101 assert.equal(r.actions[0].code, 'sudo apt-get update\nsudo apt-get install --only-upgrade php8.2-cli php8.2-common php8.2-fpm');
102 assert.match(r.actions[0].text, /install php8\.2 8\.2\.35-1~deb12u1 or later again before the service restarts/);
103 // Positive controls: the fixed package on disk is Patched, and an older package that has the fix gets a note only.
104 assert.equal(run(probe({ ...PROBE_BASE, php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: BOOKWORM_OS, dpkg_packages: deb(DEB82, '8.2.35-1~deb12u1', 'php8.2') })).verdict, 'patched');
105 const sury = run(probe({ ...PROBE_BASE, php_version: '8.3.36', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: NOBLE_OS, dpkg_packages: deb(NOBLE3, NOBLE_FIXED, 'php8.3') }));
106 assert.equal(sury.verdict, 'patched');
107 assert.match(notes(sury), /older than the running PHP .* but it has the fix too/);
108 });
109
110 test('D3 a server script output copied without its BEGIN line is still read', () => {
111 for (const text of [F3_PROBE + F3_HEADLESS, F3_HEADLESS + F3_PROBE, F3_HEADLESS]) {
112 for (const r of both(text)) assert.equal(r.verdict, 'restart', text.slice(0, 40));
113 assert.match(notes(run(text)), /no BEGIN line/);
114 }
115 // Positive control: the same text with its BEGIN line.
116 assert.equal(run(F3_PROBE + server(F3_LINES)).verdict, 'restart');
117 // Cut further down (the tool, env and os lines lost): never Patched, and the step asks for the whole output.
118 const cut = `${[`pkg|dpkg|php8.3-fpm|${NOBLE_FIXED}|php8.3`, ...logged(['php8.3-fpm'], 1), 'proc|php-fpm8.3|3|ok'].join('\n')}\n-----END PHP2SHELL SERVERCHECK-----\n`;
119 for (const r of both(cut)) assert.equal(r.verdict, 'unknown');
120 assert.match(reasons(run(cut)), /no BEGIN line and starts below its first line/);
121 // A paste with no marker at all is unchanged.
122 assert.equal(parseInput('8.2.35').phpVersion, '8.2.35');
123 });
124
125 test('D4 a running php-fpm8.3 whose php8.3-fpm package is not installed is not vouched for by php8.3-common', () => {
126 for (const text of [F6_PROBE, F6_ROOT, F6_PROBE + F6_ROOT]) {
127 for (const r of both(text)) assert.equal(r.verdict, 'unknown', text.slice(0, 40));
128 }
129 assert.match(reasons(run(F6_PROBE)), /php8\.3-fpm/);
130 assert.match(reasons(run(F6_ROOT)), /belong to none of the PHP builds/);
131 const steps = run(F6_PROBE);
132 assert.ok(steps.actions[0].code.includes('sudo dpkg --audit'));
133 // Positive control: with php8.3-fpm installed and its process checked, the backport is Patched.
134 const whole = server([...NOBLE_LINES, ...NOBLE3.map((p) => `pkg|dpkg|${p}|${NOBLE_FIXED}|php8.3`), ...logged(NOBLE3, 1), 'proc|php-fpm8.3|1|ok']);
135 assert.equal(run(whole).verdict, 'patched');
136 assert.equal(run(F3_PROBE + whole).verdict, 'patched');
137 });
138
139 test('D5 a probe and a server script that disagree are not Patched, even when the probe shows a fixed revision', () => {
140 for (const r of both(F7_PROBE + F7_SERVER)) {
141 assert.equal(r.verdict, 'unknown');
142 assert.ok(r.actions.some((a) => /together|함께/.test(a.text)));
143 }
144 const debProbe = probe({ ...PROBE_BASE, php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: BOOKWORM_OS, dpkg_packages: deb(DEB82, '8.2.35-1~deb12u1', 'php8.2') });
145 assert.equal(run(debProbe + F5_SERVER).verdict, 'unknown');
146 // The server script output alone is Vulnerable, and the probe alone is Patched.
147 assert.equal(run(F7_SERVER).verdict, 'vulnerable');
148 assert.equal(run(F7_PROBE).verdict, 'patched');
149 });
150
151 test('D6 deb.sury.org PHP 8.1 whose changelog records both backported fixes is Patched', () => {
152 for (const text of [F4_DEB, F4_PPA]) {
153 for (const r of both(text)) assert.equal(r.verdict, 'patched', text.slice(0, 60));
154 assert.match(reasons(run(text)), /backported by deb\.sury\.org/);
155 }
156 // Without the changelog the build is Undetermined, never Vulnerable, and the steps never replace a package with itself.
157 for (const text of [F4_DEB_PROBE, F4_PPA_PROBE]) {
158 for (const r of both(text)) {
159 assert.equal(r.verdict, 'unknown');
160 assert.doesNotMatch(codes(r), /make install|php-fpm -i|apt-get install libapache2-mod-php8\.1\b/);
161 }
162 }
163 const ppa = run(F4_PPA_PROBE);
164 assert.ok(ppa.actions.some((a) => /--allow-downgrades/.test(a.code || '') && /print \$1"=8\.1\.2-1ubuntu2\.28"/.test(a.code)));
165 assert.doesNotMatch(actionText(ppa), /turn off the Apache PHP module/);
166 // A changelog that was read without the fixes, or a Debian build made before the fixed releases, is Vulnerable.
167 const noFix = F4_DEB_PROBE + server([...BOOKWORM_LINES, ...FPM81.map((p) => `pkg|dpkg|${p}|${SURY_DEB}|php8.1`), ...logged(FPM81, 0), 'proc|php-fpm8.1|1|ok']);
168 assert.equal(run(noFix).verdict, 'vulnerable');
169 const old = '8.1.31-1+0~20250101.1+debian12~1.gbp1234567';
170 const before = run(probe({ ...PROBE_BASE, php_version: '8.1.31', sapi: 'fpm-fcgi', config_path: '/etc/php/8.1/fpm', os_release: BOOKWORM_OS, dpkg_packages: deb(FPM81, old, 'php8.1') }));
171 assert.equal(before.verdict, 'vulnerable');
172 assert.match(reasons(before), /before the fixed releases/);
173 });
174
175 test('D7 after a downgrade, no step reloads or restarts PHP before the fixed package is back', () => {
176 for (const r of both(F5)) {
177 assert.equal(r.verdict, 'vulnerable');
178 assert.doesNotMatch(notes(r), /restart the service when convenient|적당한 때에 서비스를 재시작/);
179 assert.doesNotMatch(codes(r), /reload/);
180 }
181 assert.match(notes(run(F5)), /Do not restart or reload them before the fixed package is installed again/);
182 // The server script output alone: stale processes over a build without the fix are not "from before the update".
183 const noble = server([...NOBLE_LINES, ...NOBLE3.map((p) => `pkg|dpkg|${p}|8.3.6-0maysync1|php8.3`), ...logged(NOBLE3, 0), 'proc|php-fpm8.3|1|stale']);
184 for (const text of [noble, F5_SERVER]) {
185 const r = run(text);
186 assert.equal(r.verdict, 'vulnerable');
187 assert.match(reasons(r), /by an update or a downgrade/);
188 assert.doesNotMatch(reasons(r), /from before the update\./);
189 const mitigation = r.actions.find((a) => /reload/.test(a.code || ''));
190 assert.ok(mitigation, 'the interim mitigation is still offered');
191 assert.match(mitigation.text, /if that change was a downgrade, install the fixed package first/);
192 }
193 // Apache: logrotate's graceful restart does not reload mod_php, so the reason no longer says it does.
194 const apache = run(probe({ ...PROBE_BASE, php_version: JAMMY_FIXED, sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, '8.1.2-1ubuntu2.20', 'php8.1') }));
195 assert.equal(apache.verdict, 'vulnerable');
196 assert.doesNotMatch(reasons(apache), /log rotation|reloads Apache/);
197 });
198
199 // ── R: RHEL family and Docker ───────────────────────────────────────
200
201 const ALMA9_LINES = ['env|root|1', 'os|ID|almalinux', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.8', 'os|PRETTY_NAME|AlmaLinux 9.8 (Olive Jaguar)'];
202 const ALMA10_LINES = ['env|root|1', 'os|ID|almalinux', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|10.2', 'os|PRETTY_NAME|AlmaLinux 10.2 (Lavender Lion)'];
203 const AL2023_LINES = ['env|root|1', 'os|ID|amzn', 'os|ID_LIKE|fedora', 'os|VERSION_ID|2023', 'os|PRETTY_NAME|Amazon Linux 2023.12.20261021'];
204 const INI = { memory_limit: '128M', 'session.upload_progress.enabled': '1', 'session.auto_start': '0', expose_php: '1' };
205 const RHEL_PROBE = { uname_release: '6.18.54.1-microsoft-standard-WSL2', open_basedir: false, dpkg_packages: null, extensions: { session: true, opcache: false }, ini: INI, self_deleted: true };
206 const rpm = (names, version) => names.map((n) => `pkg|rpm|${n}|${version}|`);
207
208 test('R1 server script output from another system is judged on its own, not dropped', () => {
209 const docker = probe({ ...RHEL_PROBE, php_version: '8.4.27', sapi: 'apache2handler', config_path: '/usr/local/etc/php', server_software: 'Apache/2.4.69 (Debian)', container: true, os_release: { PRETTY_NAME: 'Debian GNU/Linux 13 (trixie)', ID: 'debian', VERSION_ID: '13', VERSION_CODENAME: 'trixie' }, dpkg_packages: [] })
210 + server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.20.10', 'os|PRETTY_NAME|Alpine Linux v3.20', ...rpm([], ''), 'pkg|apk|php83|8.3.15-r0|', 'pkg|apk|php83-common|8.3.15-r0|', 'pkg|apk|php83-fpm|8.3.15-r0|', 'proc|php-fpm83|1|ok', 'webuser_proc|0']);
211 const remi = probe({ ...RHEL_PROBE, php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc', server_software: 'Apache/2.4.62 (AlmaLinux)', container: true, os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.8', PRETTY_NAME: 'AlmaLinux 9.8 (Olive Jaguar)' } })
212 + server([...ALMA9_LINES, ...rpm(['php74-php-common', 'php74-php-cli', 'php74-php-fpm'], '7.4.33-29.el9.remi'), ...logged(['php74-php-common', 'php74-php-cli', 'php74-php-fpm'], 0), 'proc|php-fpm|1|ok', 'webuser_proc|0']);
213 for (const text of [docker, remi]) {
214 for (const r of both(text)) assert.equal(r.verdict, 'vulnerable');
215 assert.match(notes(run(text)), /2 separate outputs/);
216 }
217 assert.match(reasons(run(docker)), /PHP 8\.3\.15/);
218 });
219
220 test('R2 a stale php-fpm next to a current one of the same name is another PHP, not the probe\'s', () => {
221 const text = probe({ ...RHEL_PROBE, php_version: '8.3.36', sapi: 'fpm-fcgi', config_path: '/etc', server_software: 'Apache/2.4.63 (AlmaLinux)', container: false, os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '10.2', PRETTY_NAME: 'AlmaLinux 10.2 (Lavender Lion)' } })
222 + server([...ALMA10_LINES, ...rpm(['php-common', 'php-fpm', 'php-cli'], '8.3.36-1.el10_2'), ...logged(['php-common', 'php-fpm', 'php-cli'], 0), 'proc|php-fpm|1|ok', 'proc|php-fpm|1|stale', 'webuser_proc|0']);
223 for (const r of both(text)) {
224 assert.equal(r.verdict, 'restart');
225 assert.ok(r.actions.some((a) => a.code === 'ps -o pid,user,cgroup,args -C php-fpm'));
226 }
227 // Positive control: stale processes alone, of the probe's SAPI, are the probe's own PHP waiting for a restart.
228 const self = run(probe({ ...RHEL_PROBE, php_version: '8.3.36', sapi: 'fpm-fcgi', config_path: '/etc', container: false, os_release: { ID: 'almalinux', VERSION_ID: '10.2' } })
229 + server([...ALMA10_LINES, ...rpm(['php-common', 'php-fpm'], '8.3.36-1.el10_2'), 'proc|php-fpm|3|stale']));
230 assert.equal(self.verdict, 'patched');
231 });
232
233 test('R3 Alpine Apache with mod_php83.so, which the server script cannot see, is not "nothing running"', () => {
234 const lines = ['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.22.7', 'os|PRETTY_NAME|Alpine Linux v3.22', 'pkg|apk|php83|8.3.36-r0|', 'pkg|apk|php83-apache2|8.3.36-r0|', 'pkg|apk|php83-common|8.3.36-r0|'];
235 for (const r of both(server([...lines, 'webuser_proc|0']))) {
236 assert.equal(r.verdict, 'unknown');
237 assert.ok(r.actions.some((a) => a.code === "ps -o pid,args | grep '[h]ttpd'"));
238 assert.ok(r.actions.some((a) => a.code === 'rc-service apache2 restart'));
239 }
240 // Positive control: once the script reports the Apache processes, they are checked like any other.
241 assert.equal(run(server([...lines, 'proc|httpd|4|ok'])).verdict, 'patched');
242 assert.equal(run(server([...lines, 'proc|httpd|4|stale'])).verdict, 'restart');
243 });
244
245 test('R4 an open_basedir probe that cannot tell whether it runs in a container is not tied to a server script output', () => {
246 const text = probe({ ...RHEL_PROBE, php_version: '8.0.30', php_extra_version: '', sapi: 'fpm-fcgi', php_os: 'Linux', config_path: '/etc', server_software: 'Apache/2.4.62 (AlmaLinux)', open_basedir: true, container: null, os_release: null })
247 + server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm', 'php-cli'], '8.0.30-8.el9_8.1'), ...logged(['php-common', 'php-fpm', 'php-cli'], 1), 'proc|php-fpm|1|ok', 'webuser_proc|0']);
248 for (const r of both(text)) assert.equal(r.verdict, 'unknown');
249 assert.match(notes(run(text)), /could not see whether it runs in a container|open_basedir kept the PHP probe from seeing/);
250 assert.equal(parseInput(text).parts[0].packages.length, 0, 'the server script packages are not credited to the probe');
251 // Positive control: a probe that knows it is not in a container is still combined.
252 const host = probe({ ...RHEL_PROBE, php_version: '8.0.30', sapi: 'fpm-fcgi', config_path: '/etc', container: false, os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.8' } })
253 + server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm', 'php-cli'], '8.0.30-8.el9_8.1'), ...logged(['php-common', 'php-fpm', 'php-cli'], 1), 'proc|php-fpm|1|ok']);
254 assert.equal(run(host).verdict, 'patched');
255 });
256
257 test('R6 a Remi collection of an end-of-life branch with no changelog read is Undetermined, not Vulnerable', () => {
258 const remiProbe = probe({ ...RHEL_PROBE, php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/opt/remi/php74', server_software: 'Apache/2.4.62 (AlmaLinux)', container: false, os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.8', PRETTY_NAME: 'AlmaLinux 9.8 (Olive Jaguar)' } });
259 for (const r of both(remiProbe)) {
260 assert.equal(r.verdict, 'unknown');
261 assert.match(r.actions[0].code, /php2shell-server-check\.sh$/);
262 }
263 assert.match(reasons(run(remiProbe)), /changelog/);
264 assert.doesNotMatch(all(run(remiProbe)), /follows upstream versions/);
265 // With the changelog of exactly this build: Patched when it records both, Vulnerable when it was read without them.
266 const pkgs = ['php74-php-common', 'php74-php-cli', 'php74-php-fpm'];
267 assert.equal(run(remiProbe + server([...ALMA9_LINES, ...rpm(pkgs, '7.4.33-31.el9.remi'), ...logged(pkgs, 1), 'proc|php-fpm|1|ok'])).verdict, 'patched');
268 assert.equal(run(remiProbe + server([...ALMA9_LINES, ...rpm(pkgs, '7.4.33-29.el9.remi'), ...logged(pkgs, 0), 'proc|php-fpm|1|ok'])).verdict, 'vulnerable');
269 });
270
271 test('R7 a RHEL build older than the build Red Hat names as fixed, with no changelog entry, is Vulnerable', () => {
272 const alma = server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm', 'php-cli'], '8.0.30-8.el9_8'), ...logged(['php-common', 'php-fpm', 'php-cli'], 0), 'proc|php-fpm|1|ok', 'webuser_proc|0']);
273 const rhel10 = server([...ALMA10_LINES, ...rpm(['php-common', 'php-fpm'], '8.3.30-1.el10_1'), ...logged(['php-common', 'php-fpm'], 0), 'proc|php-fpm|1|ok']);
274 const al2 = server(['env|root|1', 'os|ID|amzn', 'os|ID_LIKE|centos rhel fedora', 'os|VERSION_ID|2', ...rpm(['php-common', 'php-fpm'], '8.2.30-1.amzn2.0.1'), ...logged(['php-common', 'php-fpm'], 0), 'proc|php-fpm|1|ok']);
275 for (const text of [alma, rhel10, al2]) {
276 for (const r of both(text)) assert.equal(r.verdict, 'vulnerable', text.slice(60, 160));
277 }
278 assert.match(reasons(run(alma)), /older than 8\.0\.30-8\.el9_8\.1, the build that fixes both CVEs \(RHSA-2026:87151\)/);
279 // A module stream build: the advisory names no package version, so it stays Undetermined.
280 const module = server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm'], '8.2.13-1.module_el9.4.0+20844+7c41d8d0'), ...logged(['php-common', 'php-fpm'], 0), 'proc|php-fpm|1|ok']);
281 assert.equal(run(module).verdict, 'unknown');
282 });
283
284 test('R8 Amazon Linux 2023: no "built from source" claim, and the update is confirmed by its version', () => {
285 const text = probe({ ...RHEL_PROBE, php_version: '8.3.20', sapi: 'fpm-fcgi', config_path: '/etc', server_software: 'Apache/2.4.69 (Amazon Linux)', container: false, os_release: { ID: 'amzn', ID_LIKE: 'fedora', VERSION_ID: '2023', PRETTY_NAME: 'Amazon Linux 2023.12.20261021' } })
286 + server([...AL2023_LINES, 'proc|php-fpm|1|ok', 'webuser_proc|0']);
287 for (const r of both(text)) {
288 assert.equal(r.verdict, 'vulnerable');
289 assert.doesNotMatch(codes(r), /rpm -q --changelog php-common/);
290 }
291 assert.doesNotMatch(notes(run(text)), /so this PHP was built from source or comes/);
292 assert.match(notes(run(text)), /current server script/);
293 assert.match(actionText(run(text)), /each package version below should now start with 8\.3\.36/);
294 // The server script now lists php8.3-* packages: the upstream fixed release is Patched, an older one Vulnerable.
295 const pkgs = ['php8.3-common', 'php8.3-cli', 'php8.3-fpm'];
296 assert.equal(run(server([...AL2023_LINES, ...rpm(pkgs, '8.3.36-1.amzn2023.0.1'), ...logged(pkgs, 0), 'proc|php-fpm|1|ok'])).verdict, 'patched');
297 assert.equal(run(server([...AL2023_LINES, ...rpm(pkgs, '8.3.20-1.amzn2023.0.1'), ...logged(pkgs, 0), 'proc|php-fpm|1|ok'])).verdict, 'vulnerable');
298 });
299
300 // ── C: crafted inputs ───────────────────────────────────────────────
301
302 const D12_PROBE = probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' }, dpkg_packages: [{ name: 'php8.2-fpm', version: '8.2.35-1~deb12u1', source: 'php8.2' }], container: false, open_basedir: false });
303 const D12_SERVER = ['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'os|VERSION_CODENAME|bookworm', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2'];
304
305 test('C1 ok and stale lines of the same php-fpm name are two masters: the stale one is not the probe\'s', () => {
306 for (const r of both(D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|ok', 'proc|php-fpm8.2|2|stale']))) assert.equal(r.verdict, 'restart');
307 // Positive control: all stale, so the probe's own pool waits for a restart.
308 assert.equal(run(D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|stale'])).verdict, 'patched');
309 });
310
311 test('C2 PHP processes the server script could not check rule out Patched, also next to a probe that shows the fix', () => {
312 for (const last of ['proc|unreadable|4|unknown', 'proc|apache2|4|']) {
313 for (const r of both(D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|ok', last]))) assert.equal(r.verdict, 'unknown', last);
314 }
315 assert.equal(run(D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|ok'])).verdict, 'patched');
316 });
317
318 test('C3 without root, processes the script saw are not all that run: a web-serving build is not Patched', () => {
319 const jammy = server(['env|root|0', 'os|ID|ubuntu', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1',
320 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm8.1|2|ok']);
321 const rocky = server(['env|root|0', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...rpm(['php-common', 'php-fpm'], '8.2.13-1.module+el9.4.0+20827+3f2b0b48'),
322 ...logged(['php-common', 'php-fpm'], 1), 'proc|php-fpm|3|ok']);
323 for (const text of [jammy, rocky]) {
324 for (const r of both(text)) {
325 assert.equal(r.verdict, 'unknown');
326 assert.match(r.actions[0].text, /sudo|root/);
327 }
328 }
329 });
330
331 test('C4 a fixed running PHP over an older unfixed package of its own SAPI is Vulnerable', () => {
332 const os12 = { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' };
333 const cases = [
334 probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: os12, dpkg_packages: deb(['php8.2-fpm', 'php8.2-common'], '8.2.29-1~deb12u1', 'php8.2'), container: false, open_basedir: false }),
335 probe({ php_version: '8.2.35', sapi: 'apache2handler', config_path: '/etc/php/8.2/apache2', os_release: os12, dpkg_packages: deb(['libapache2-mod-php8.2'], '8.2.29-1~deb12u1', 'php8.2'), container: false, open_basedir: false }),
336 probe({ php_version: '8.3.36', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: { ID: 'ubuntu', VERSION_ID: '24.04' }, dpkg_packages: deb(['php8.3-fpm'], '8.3.6-0ubuntu0.24.04.4', 'php8.3'), container: false, open_basedir: false }),
337 ];
338 for (const text of cases) {
339 for (const r of both(text)) assert.equal(r.verdict, 'vulnerable');
340 }
341 // Positive control: a PHP configured outside the package's directory is not that package.
342 assert.equal(run(probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/opt/php/etc', os_release: os12, dpkg_packages: deb(['php8.2-fpm'], '8.2.29-1~deb12u1', 'php8.2') })).verdict, 'patched');
343 });
344
345 test('C5 C6 every PHP version in a paste is judged: bare lines, Server headers and merged X-Powered-By values', () => {
346 const pastes = [
347 '8.4.27\n8.4.20\n',
348 'PHP/8.4.27\nPHP/8.4.20\n',
349 'HTTP/1.1 301 Moved Permanently\nServer: Apache/2.4.62 (Unix) PHP/8.4.27\nLocation: https://www.example.com/\n\nHTTP/1.1 200 OK\nServer: Apache/2.4.29 (Unix) PHP/8.4.20\n',
350 'HTTP/1.1 301 Moved Permanently\nX-Powered-By: PHP/8.4.27\n\nHTTP/2 200\nx-powered-by: PleskLin, PHP/8.4.20\n',
351 'X-Powered-By: PHP/8.4.27, PHP/8.4.20\n',
352 ];
353 for (const text of pastes) {
354 for (const r of both(text)) assert.equal(r.verdict, 'vulnerable', text);
355 assert.equal(parseInput(text).parts.length, 2, text);
356 }
357 // The tester's own versions (8.2.20, 7.4.33) may be RHEL module builds: Undetermined, never Patched.
358 for (const text of ['8.4.27\n8.2.20\n', 'X-Powered-By: PHP/8.4.27, PHP/8.2.20\n', 'HTTP/1.1 301 Moved\nServer: Apache/2.4.62 (Unix) PHP/8.4.27\n\nHTTP/1.1 200 OK\nServer: Apache/2.4.29 (Unix) PHP/7.4.33\n']) {
359 assert.notEqual(run(text).verdict, 'patched', text);
360 }
361 // Positive controls: one version alone, and the same version twice, are one output.
362 assert.equal(run('8.4.27').verdict, 'patched');
363 assert.equal(parseInput('X-Powered-By: PHP/8.4.27\nServer: nginx PHP/8.4.27\n').parts, undefined);
364 });
365
366 test('C7 php5-fpm, which the server script cannot see, is not "no running PHP"; a php5-fpm line maps to its build', () => {
367 const trusty = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|14.04', 'os|PRETTY_NAME|Ubuntu 14.04.6 LTS',
368 'pkg|dpkg|php5-fpm|5.5.9+dfsg-1ubuntu4.29+esm18|php5', 'pkg|dpkg|php5-common|5.5.9+dfsg-1ubuntu4.29+esm18|php5', 'pkg|dpkg|php5-cli|5.5.9+dfsg-1ubuntu4.29+esm18|php5'];
369 for (const r of both(server(trusty))) {
370 assert.equal(r.verdict, 'unknown');
371 assert.ok(r.actions.some((a) => a.code === 'ps -o pid,lstart,args -C php5-fpm'));
372 assert.ok(r.actions.some((a) => a.code === 'sudo service php5-fpm restart'));
373 }
374 assert.equal(run(server([...trusty, 'proc|php5-fpm|3|ok'])).verdict, 'patched');
375 assert.equal(run(server([...trusty, 'proc|php5-fpm|3|stale'])).verdict, 'restart');
376 });
377
378 test('C8 an open_basedir probe of unknown container status is not judged by an Ubuntu host\'s package', () => {
379 const text = probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: null, open_basedir: true, container: null, dpkg_packages: null, uname_release: '6.8.0-45-generic' })
380 + server(['env|root|1', 'os|ID|ubuntu', 'os|VERSION_ID|24.04', `pkg|dpkg|php8.3-fpm|${NOBLE_FIXED}|php8.3`, 'proc|php-fpm8.3|4|ok']);
381 for (const r of both(text)) assert.equal(r.verdict, 'unknown');
382 });
383
384 test('C9 a Debian snapshot version with "~" before the release is a pre-release', () => {
385 for (const r of both(server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|13', 'pkg|dpkg|php8.4-fpm|8.4.27~git20261001-1|php8.4', 'proc|php-fpm8.4|2|ok']))) {
386 assert.equal(r.verdict, 'unknown');
387 assert.match(reasons(r), /8\.4\.27~git20261001-1/);
388 }
389 assert.equal(run(server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|13', 'pkg|dpkg|php8.4-fpm|8.4.27-1~deb13u1|php8.4', 'proc|php-fpm8.4|2|ok'])).verdict, 'patched');
390 });
391
392 test('C10 a bare 8.2.x or 8.3.x below the fix may be a RHEL module stream build', () => {
393 for (const text of ['X-Powered-By: PHP/8.2.13\n', 'PHP 8.2.13 (cli) (built: Oct 20 2026 12:00:00) (NTS)', 'X-Powered-By: PHP/8.3.20\n']) {
394 for (const r of both(text)) assert.equal(r.verdict, 'unknown', text);
395 assert.match(reasons(run(text)), /RHEL (8, RHEL 9|9)/, text);
396 }
397 // Positive controls: a branch with no such fix, and a Distribution list choice, are judged by version.
398 assert.equal(run('X-Powered-By: PHP/8.4.20').verdict, 'vulnerable');
399 assert.equal(run('X-Powered-By: PHP/8.2.13', { distro: 'debian:12' }).verdict, 'vulnerable');
400 });
401
402 test('C11 a Remi end-of-life collection whose changelog was not read is Undetermined', () => {
403 const remi = { php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/opt/remi/php74', os_release: { ID: 'rocky', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.4' }, dpkg_packages: null, container: false, open_basedir: false };
404 const lines = ['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', 'pkg|rpm|php74-php-common|7.4.33-18.el9.remi|', 'pkg|rpm|php74-php-fpm|7.4.33-18.el9.remi|', 'proc|php-fpm|3|ok'];
405 for (const text of [probe(remi), server(lines), server([...lines, ...logged(['php74-php-common', 'php74-php-fpm'], { 'CVE-2026-109618': 'unknown', 'CVE-2026-109619': 'unknown' })])]) {
406 for (const r of both(text)) assert.equal(r.verdict, 'unknown', text.slice(0, 80));
407 }
408 });
409
410 test('C12 SUSE backports into its own PHP packages: Undetermined, with zypper and no RHEL package or source build', () => {
411 const sles = probe({ php_version: '8.0.30', sapi: 'apache2handler', config_path: '/etc/php8/apache2', os_release: { ID: 'sles', ID_LIKE: 'suse', VERSION_ID: '15.5', PRETTY_NAME: 'SUSE Linux Enterprise Server 15 SP5' }, dpkg_packages: null, container: false, open_basedir: false });
412 for (const r of both(sles)) {
413 assert.equal(r.verdict, 'unknown');
414 assert.doesNotMatch(codes(r), /make install|git clone|dnf|apt-get/);
415 assert.ok(r.actions.some((a) => a.code === "sudo zypper refresh\nsudo zypper update 'php8*'"));
416 assert.ok(r.actions.some((a) => /rpm -q --changelog php8 \| grep -e CVE-2026-109618 -e CVE-2026-109619/.test(a.code || '')));
417 }
418 assert.doesNotMatch(actionText(run(sles)), /RHEL 9 \(/);
419 });
420
421 test('C13 CentOS 6: the server script\'s "rhel" from /etc/redhat-release is the same system as the probe\'s CentOS', () => {
422 const text = probe({ php_version: '5.3.3', sapi: 'apache2handler', config_path: '/etc', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' }, open_basedir: false, container: false, dpkg_packages: null, uname_release: '2.6.32-754.35.1.el6.x86_64' })
423 + server(['env|root|1', 'os|ID|rhel', 'os|VERSION_ID|6', 'pkg|rpm|php|5.3.3-50.el6_10|', 'pkg|rpm|php-common|5.3.3-50.el6_10|', ...logged(['php', 'php-common'], 1), 'proc|httpd|5|ok']);
424 for (const r of both(text)) assert.equal(r.verdict, 'patched');
425 assert.doesNotMatch(notes(run(text)), /another system/);
426 // A different release is still another system.
427 const other = probe({ php_version: '5.3.3', sapi: 'apache2handler', config_path: '/etc', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' }, dpkg_packages: null })
428 + server(['env|root|1', 'os|ID|rhel', 'os|VERSION_ID|7', 'pkg|rpm|php|5.4.16-48.el7|', 'proc|httpd|5|ok']);
429 assert.match(notes(run(other)), /another system/);
430 });
431
432 test('C14 processes unreadable as root: the advice is the host or the probe, not sudo again', () => {
433 const text = server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|unreadable|3|unknown']);
434 for (const r of both(text)) {
435 assert.equal(r.verdict, 'unknown');
436 assert.match(r.actions[0].text, /CAP_SYS_PTRACE/);
437 }
438 const r = run(text);
439 assert.doesNotMatch(notes(r), /missing permissions/);
440 assert.doesNotMatch(r.actions[0].text, /^Run the server script again as root \(with sudo\)/);
441 // Without root it is a matter of permissions.
442 const nonRoot = run(server(['env|root|0', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|unreadable|3|unknown']));
443 assert.match(notes(nonRoot), /missing permissions/);
444 });
445
446 test('C15 end-of-life steps use the real binary names, warn about deb.sury.org, and build from source only for a self-built PHP', () => {
447 const jammySury = probe({ php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/php/7.4/fpm', os_release: { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' },
448 dpkg_packages: [{ name: 'php7.4-fpm', version: '1:7.4.33-1+ubuntu22.04.1+deb.sury.org+1', source: 'php7.4' }], container: false, open_basedir: false });
449 for (const r of both(jammySury)) {
450 assert.notEqual(r.verdict, 'patched');
451 assert.doesNotMatch(codes(r), /php-fpm -i|make install/);
452 const host = r.actions.find((a) => /apt-get install php8\.1-fpm/.test(a.code || ''));
453 assert.ok(host);
454 assert.match(host.code, /apt-cache policy php8\.1-fpm/);
455 assert.match(host.text, /deb\.sury\.org/);
456 }
457 const alpine = server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.19.4', 'pkg|apk|php81|8.1.31-r0|', 'pkg|apk|php81-fpm|8.1.31-r0|', 'proc|php-fpm81|2|ok']);
458 for (const r of both(alpine)) {
459 assert.equal(r.verdict, 'vulnerable');
460 assert.doesNotMatch(codes(r), /php-fpm -i|make install/);
461 }
462 // A self-built PHP still gets the php-src backport branch, with the binary of its SAPI.
463 const self = run(probe({ php_version: '8.1.30', sapi: 'fpm-fcgi', config_path: '/usr/local/lib', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [] }));
464 assert.match(codes(self), /^php-fpm -i \| grep -i 'configure command'\ngit clone/m);
465 });
466
467 // ── X: rendering and robustness ─────────────────────────────────────
468
469 test('X1 a SAPI named like an Object member never throws and is shown as it is', () => {
470 for (const sapi of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__', 'isPrototypeOf', 'toLocaleString', '__defineGetter__']) {
471 for (const os of [{ ID: 'ubuntu', VERSION_ID: '22.04' }, null]) {
472 const text = probe({ php_version: '8.1.2-1ubuntu2.20', sapi, config_path: '/etc/php/8.1/fpm', os_release: os, dpkg_packages: [{ name: 'php8.1-common', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }] });
473 for (const r of both(text)) {
474 assert.ok(['vulnerable', 'unknown'].includes(r.verdict), `${sapi}: ${r.verdict}`);
475 for (const value of [...r.facts.map((f) => f.value), r.exposure?.text || '', ...r.actions.map((a) => `${a.text} ${a.code || ''}`)]) {
476 assert.doesNotMatch(value, /function |native code|\[object /, `${sapi}: ${value}`);
477 }
478 }
479 }
480 assert.equal(sapiLabel(sapi), sapi);
481 }
482 });
483
484 test('X2 X3 settings and OS release values that are not text are left out, never shown as [object Object]', () => {
485 const texts = [
486 JSON.stringify({ tool: 'php2shell-selfcheck', php_version: '8.2.20', sapi: 'fpm-fcgi', ini: { expose_php: { toString: 0 } } }),
487 JSON.stringify({ tool: 'php2shell-selfcheck', php_version: '8.2.20', sapi: 'fpm-fcgi', ini: { memory_limit: {}, 'session.upload_progress.enabled': [], expose_php: 1 } }),
488 probe({ php_version: '8.2.20', sapi: 'fpm-fcgi', os_release: { ID: { a: 1 }, VERSION_ID: '9' } }),
489 probe({ php_version: '8.2.20', sapi: 'fpm-fcgi', os_release: { ID: 'ubuntu', VERSION_ID: ['2', '2'] } }),
490 probe({ php_version: '8.2.20', sapi: 'fpm-fcgi', dpkg_packages: [{ name: { x: 1 }, version: '1' }, { name: 'php8.2-fpm', version: ['8.2.20'] }] }),
491 ];
492 for (const text of texts) {
493 for (const r of both(text)) {
494 const shown = [...r.facts.map((f) => f.value), ...r.notes, ...r.reasons, ...r.mitigations.map((m) => `${m.name} ${m.value} ${m.note}`)].join('\n');
495 assert.doesNotMatch(shown, /\[object |undefined/, text);
496 }
497 }
498 const mit = run(texts[1]).mitigations;
499 assert.equal(mit.find((m) => m.name === 'memory_limit').value, '(not set)');
500 assert.equal(mit.find((m) => m.name === 'expose_php').value, '1');
501 });
502
503 test('X4 a large paste is read in time proportional to its size', () => {
504 const markers = '-----BEGIN PHP2SHELL SELFCHECK-----\n'.repeat(58000);
505 let start = Date.now();
506 const r1 = run(markers);
507 assert.ok(Date.now() - start < 3000, `marker paste took ${Date.now() - start} ms`);
508 assert.notEqual(r1.verdict, 'patched');
509 start = Date.now();
510 const r2 = run(`PHP 8.1.${'7'.repeat(64000)}`);
511 assert.ok(Date.now() - start < 1000, `digit paste took ${Date.now() - start} ms`);
512 assert.notEqual(r2.verdict, 'patched');
513 // An input over the limit is cut, and what was cut may be worse, so it is never Patched.
514 const long = `X-Powered-By: PHP/8.4.27\n${' '.repeat(2000001)}\nX-Powered-By: PHP/8.2.20\n`;
515 const r3 = run(long);
516 assert.notEqual(r3.verdict, 'patched');
517 assert.match(notes(r3), /2 million characters/);
518 });
519
520 test('X5 a changelog line cannot change the changelog of another package', () => {
521 const text = server(['env|root|1', 'os|ID|rocky', 'os|VERSION_ID|9.4', 'pkg|rpm|php-common|8.2.13-1.module+el9.4.0+20827+3f2b0b48|', 'pkg|rpm|php-fpm|8.2.13-1.module+el9.4.0+20827+3f2b0b48|',
522 'changelog|__proto__|CVE-2026-109618|1', 'changelog|__proto__|CVE-2026-109619|1', 'changelog|php-common|__proto__|1', 'log|__proto__|1', 'proc|php-fpm|2|ok']);
523 for (const r of both(text)) assert.equal(r.verdict, 'unknown');
524 assert.equal({}['CVE-2026-109618'], undefined, 'nothing reaches Object.prototype');
525 });
526
527 test('X6 the engine never throws, so the page always shows a result', () => {
528 for (const obs of [{}, { parts: [{}, null] }, { phpVersion: '8.2.20', packages: null }]) {
529 for (const lang of ['en', 'ko']) {
530 const r = judge(obs, upstream, distro, { lang });
531 assert.equal(r.verdict, 'unknown');
532 assert.equal(typeof r.title, 'string');
533 assert.ok(r.reasons.length > 0);
534 for (const k of ['actions', 'facts', 'mitigations', 'notes', 'advisories', 'findings']) assert.ok(Array.isArray(r[k]), k);
535 }
536 }
537 // A probe output copied without its BEGIN line is read too.
538 const headless = `php2shell-selfcheck 1.0.0\n${JSON.stringify({ tool: 'php2shell-selfcheck', php_version: '8.4.27', sapi: 'fpm-fcgi' })}\n-----END PHP2SHELL SELFCHECK-----\n`;
539 assert.equal(parseInput(headless).phpVersion, '8.4.27');
540 });
541
542 // ── N: notes from the server script and probe work ──────────────────
543
544 const ALMA10_84 = (version, n = 0) => [...ALMA10_LINES, ...rpm(['php8.4-common', 'php8.4-cli', 'php8.4-fpm', 'php8.4'], version),
545 ...logged(['php8.4-common', 'php8.4-cli', 'php8.4-fpm', 'php8.4'], n)];
546 const AL2023_84 = (version) => [...AL2023_LINES, ...rpm(['php8.4-common', 'php8.4-cli', 'php8.4-fpm', 'php8.4-modphp'], version),
547 ...logged(['php8.4-common', 'php8.4-cli', 'php8.4-fpm', 'php8.4-modphp'], 0)];
548
549 test('N1 RHEL 10 and Amazon Linux 2023 php8.4 packages run php-fpm and httpd', () => {
550 for (const text of [server([...ALMA10_84('8.4.27-1.el10_2'), 'proc|php-fpm|1|ok', 'webuser_proc|unknown']),
551 server([...AL2023_84('8.4.27-1.amzn2023.0.1'), 'proc|httpd|1|ok', 'proc|php-fpm|1|ok'])]) {
552 for (const r of both(text)) assert.equal(r.verdict, 'patched');
553 }
554 for (const text of [server([...ALMA10_84('8.4.27-1.el10_2'), 'proc|php-fpm|1|stale']), server([...AL2023_84('8.4.27-1.amzn2023.0.1'), 'proc|php-fpm|1|stale'])]) {
555 const r = run(text);
556 assert.equal(r.verdict, 'restart');
557 assert.equal(r.actions[0].code, 'sudo systemctl restart php-fpm');
558 }
559 });
560
561 test('N2 an update that is a new upstream release is confirmed by its version, not by CVE lines in the changelog', () => {
562 const alma = run(server([...ALMA10_84('8.4.24-1.el10_2'), 'proc|php-fpm|1|ok']));
563 assert.equal(alma.verdict, 'vulnerable');
564 assert.ok(alma.actions.some((a) => a.code === 'rpm -q php8.4-common php8.4-cli php8.4-fpm php8.4' && /8\.4\.27-1\.el10_2 or later/.test(a.text)));
565 assert.doesNotMatch(codes(alma), /rpm -q --changelog/);
566 const amazon = run(server([...AL2023_84('8.4.26-1.amzn2023.0.1'), 'proc|php-fpm|1|ok']));
567 assert.equal(amazon.verdict, 'vulnerable');
568 assert.doesNotMatch(codes(amazon), /rpm -q --changelog/);
569 // A Red Hat backport is still confirmed in the changelog.
570 const rhel9 = run(server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm'], '8.0.30-8.el9_8'), ...logged(['php-common', 'php-fpm'], 0), 'proc|php-fpm|1|ok']));
571 assert.match(codes(rhel9), /rpm -q --changelog php-common \| grep -e CVE-2026-109618 -e CVE-2026-109619/);
572 });
573
574 test('every result of this round is the same shape in both languages', () => {
575 const inputs = [F1, F2, F3_PROBE + F3_HEADLESS, F6_PROBE, F7_PROBE + F7_SERVER, F4_DEB, F4_PPA_PROBE, F5, D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|ok', 'proc|unreadable|4|unknown'])];
576 for (const text of inputs) {
577 const [en, ko] = both(text);
578 assert.equal(ko.verdict, en.verdict);
579 for (const k of ['reasons', 'notes', 'actions', 'facts', 'advisories']) assert.equal(ko[k].length, en[k].length, k);
580 for (const a of ko.actions) assert.match(a.text, /[가-힣]/);
581 assert.deepEqual(verdicts(text), [en.verdict, en.verdict]);
582 }
583 });