test/retest.test.mjs (28000B)
1 // Regression tests for the second round of tester reports. Each test replays the tester's input against
2 // upstream.json and a frozen copy of distro.json (fixtures/distro-snapshot.json), so data refreshes do not move the
3 // verdicts. Numbers follow the report: tester 1 #1-#12, tester 2 #13-#30.
4 import { test } from 'node:test';
5 import assert from 'node:assert/strict';
6 import { readFileSync } from 'node:fs';
7 import { parseInput } from '../site/lib/parse.js';
8 import { judge } from '../site/lib/judge.js';
9 import { parsePhpVersion } from '../site/lib/version.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', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
15 const server = (lines, closed = true) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${lines.join('\n')}\n${closed ? '-----END PHP2SHELL SERVERCHECK-----\n' : ''}`;
16 const run = (text, options = {}) => judge(parseInput(text, { lang: options.lang }), upstream, distro, options);
17 const reasons = (r) => r.reasons.join(' ');
18 const notes = (r) => r.notes.join(' ');
19 const fact = (r, label) => r.facts.find((f) => f.label === label)?.value;
20 const actionText = (r) => r.actions.map((a) => `${a.text} ${a.code || ''}`).join(' ');
21 const both = (text, options = {}) => ['en', 'ko'].map((lang) => run(text, { ...options, lang }));
22
23 const NOBLE = { ID: 'ubuntu', VERSION_ID: '24.04', VERSION_CODENAME: 'noble' };
24 const JAMMY = { ID: 'ubuntu', VERSION_ID: '22.04' };
25 const ROCKY94 = { ID: 'rocky', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.4' };
26 const NOBLE_HOST = ['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3'];
27 const ROCKY_RPMS = ['pkg|rpm|php-common|8.2.13-1.module+el9.4.0+20827+3f2b0b48|', 'pkg|rpm|php-fpm|8.2.13-1.module+el9.4.0+20827+3f2b0b48|',
28 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|1', 'changelog|php-fpm|CVE-2026-109618|1', 'changelog|php-fpm|CVE-2026-109619|1'];
29
30 // ── Tester 1 ────────────────────────────────────────────────────────
31
32 test('#1 a container probe that could not read its own OS is not judged by host packages', () => {
33 const text = probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', container: true, open_basedir: false, os_release: null, dpkg_packages: null, config_path: '/etc/php/8.3/fpm', uname_release: '6.8.0-45-generic' })
34 + server([...NOBLE_HOST, 'proc|php-fpm8.3|4|ok']);
35 for (const r of both(text)) {
36 assert.equal(r.verdict, 'unknown');
37 assert.match(notes(r), /docker exec/);
38 }
39 assert.equal(parseInput(text).packages.length, 0, 'host packages are not used');
40 });
41
42 test('#2 a container probe with an unreadable package list is not tied to same-OS host output', () => {
43 const r = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', container: true, open_basedir: false, os_release: NOBLE, dpkg_packages: null, config_path: '/etc/php/8.3/fpm' })
44 + server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'proc|php-fpm8.3|4|ok']));
45 assert.equal(r.verdict, 'unknown');
46 assert.match(notes(r), /nothing in its output shows that the server script ran in the same container/);
47 // Positive control: the server script run inside the container lists the same packages as the probe.
48 const pkgs = [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }];
49 const linked = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', container: true, os_release: NOBLE, dpkg_packages: pkgs, config_path: '/etc/php/8.3/fpm' })
50 + server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'proc|php-fpm8.3|4|ok']));
51 assert.equal(linked.verdict, 'patched');
52 });
53
54 test('#3 a changelog read at one version is never credited to another version of the package', () => {
55 const host = server(['os|ID|debian', 'os|VERSION_ID|11', 'env|root|1', 'pkg|dpkg|php7.4-fpm|7.4.33-1+deb11u9|php7.4',
56 'changelog|php7.4-fpm|CVE-2026-109618|1', 'changelog|php7.4-fpm|CVE-2026-109619|1', 'proc|php-fpm7.4|3|ok']);
57 const u5 = { php_version: '7.4.33-1+deb11u5', sapi: 'fpm-fcgi', open_basedir: false, os_release: { ID: 'debian', VERSION_ID: '11', VERSION_CODENAME: 'bullseye' },
58 dpkg_packages: [{ name: 'php7.4-fpm', version: '7.4.33-1+deb11u5', source: 'php7.4' }], config_path: '/etc/php/7.4/fpm' };
59 for (const container of [true, false]) {
60 const r = run(probe({ ...u5, container }) + host);
61 assert.equal(r.verdict, 'unknown', `container ${container}`);
62 assert.doesNotMatch(r.reasons.filter((x) => !/^Server script output/.test(x)).join(' '), /treated as fixed/);
63 }
64 // The changelog is kept with the version it was read at.
65 const obs = parseInput(host);
66 assert.deepEqual(obs.packages[0].changelog, { 'CVE-2026-109618': 1, 'CVE-2026-109619': 1 });
67 assert.equal(obs.packages[0].version, '7.4.33-1+deb11u9');
68 });
69
70 test('#4 a non-root server script run that saw no PHP process is not Patched', () => {
71 const ubuntu = server(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'env|root|0', 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1']);
72 const alma = server(['os|ID|almalinux', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.6', 'env|root|0', 'pkg|rpm|php-common|8.0.30-8.el9_8.1|', 'pkg|rpm|php-fpm|8.0.30-8.el9_8.1|',
73 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2', 'changelog|php-fpm|CVE-2026-109618|2', 'changelog|php-fpm|CVE-2026-109619|2']);
74 for (const text of [ubuntu, alma]) {
75 for (const r of both(text)) {
76 assert.equal(r.verdict, 'unknown');
77 assert.match(reasons(r), /hidepid/);
78 }
79 }
80 // With a probe whose PHP_VERSION cannot show a restart (Ubuntu 24.04), it is the same gap; it never says no PHP is
81 // running.
82 const pkgs = [{ name: 'php8.3-common', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }, { name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }];
83 const noble = server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|0', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3']);
84 const withProbe = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', os_release: NOBLE, dpkg_packages: pkgs, config_path: '/etc/php/8.3/fpm' }) + noble);
85 assert.equal(withProbe.verdict, 'unknown');
86 assert.doesNotMatch(reasons(withProbe), /No running PHP process/);
87 // As root, with no PHP process found, the probe's PHP still runs: no "none found" claim either.
88 const rootNone = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', os_release: NOBLE, dpkg_packages: pkgs, config_path: '/etc/php/8.3/fpm' })
89 + server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3']));
90 assert.equal(rootNone.verdict, 'patched');
91 assert.doesNotMatch(reasons(rootNone), /No running PHP process/);
92 assert.match(reasons(rootNone), /found no running process of this PHP/);
93 });
94
95 test('#5 a wrong manual distribution never overrides the os-release or the revision vendor', () => {
96 const deb9 = probe({ php_version: '7.0.33-0+deb9u12', sapi: 'apache2handler', os_release: { ID: 'debian', VERSION_ID: '9', VERSION_CODENAME: 'stretch' },
97 dpkg_packages: [{ name: 'libapache2-mod-php7.0', version: '7.0.33-0+deb9u12', source: 'php7.0' }], config_path: '/etc/php/7.0/apache2' });
98 for (const text of [deb9, 'X-Powered-By: PHP/7.0.33-0+deb9u12']) {
99 for (const [lang, label] of [['en', 'Distribution'], ['ko', '배포판']]) {
100 const r = run(text, { distro: 'ubuntu:16.04', lang });
101 assert.equal(r.verdict, 'unknown');
102 assert.doesNotMatch(reasons(r), /backported the fixes|백포트했습니다/);
103 assert.match(fact(r, label), /Debian 9/);
104 assert.match(notes(r), /Ubuntu 16\.04/);
105 }
106 }
107 });
108
109 test('#6 a revision Ubuntu never built is not credited with the Ubuntu backport', () => {
110 const ppa = run(server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-1+ppa1|php8.3', 'proc|php-fpm8.3|2|ok']));
111 assert.equal(ppa.verdict, 'unknown');
112 assert.match(reasons(ppa), /not one that Ubuntu builds/);
113 const debStyle = run(probe({ php_version: '8.1.2-2', sapi: 'apache2handler', os_release: JAMMY,
114 dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-2', source: 'php8.1' }], config_path: '/etc/php/8.1/apache2' }));
115 assert.equal(debStyle.verdict, 'unknown');
116 // Positive control: Ubuntu's own revision is still Patched, and an older Ubuntu revision still Vulnerable.
117 assert.equal(run('X-Powered-By: PHP/8.1.2-1ubuntu2.28').verdict, 'patched');
118 assert.equal(run('X-Powered-By: PHP/8.1.2-1ubuntu2.20').verdict, 'vulnerable');
119 });
120
121 test('#7 an installed package downgraded below the running fixed build is not Patched', () => {
122 const [en, ko] = both(probe({ php_version: '8.1.2-1ubuntu2.28', sapi: 'apache2handler', os_release: JAMMY,
123 dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }], config_path: '/etc/php/8.1/apache2' }));
124 assert.equal(en.verdict, 'vulnerable');
125 assert.match(reasons(en), /next restart of the service loads the vulnerable build/);
126 assert.equal(ko.verdict, 'vulnerable');
127 });
128
129 test('#8 a bare 8.0.30 or 7.4.33 could be a RHEL package with the fix backported', () => {
130 for (const text of ['X-Powered-By: PHP/8.0.30', 'PHP 8.0.30 (cli) (built: Oct 20 2026 12:00:00) (NTS)', 'X-Powered-By: PHP/7.4.33',
131 probe({ php_version: '8.0.30', sapi: 'fpm-fcgi', os_release: null, open_basedir: false, uname_release: '', config_path: '/etc', dpkg_packages: null })]) {
132 const r = run(text);
133 assert.equal(r.verdict, 'unknown', text);
134 assert.match(reasons(r), /RHEL (8|9)/, text);
135 }
136 // Positive controls: a version no distribution ships with a backport is still judged by upstream.
137 assert.equal(run('X-Powered-By: PHP/8.0.29').verdict, 'vulnerable');
138 assert.equal(run('8.1.30').verdict, 'vulnerable');
139 });
140
141 test('#9 a manual release that contradicts the os-release is not used', () => {
142 const r = run(probe({ php_version: '8.5.4-0ubuntu1.5', sapi: 'apache2handler', os_release: { ID: 'ubuntu', VERSION_ID: '26.04', VERSION_CODENAME: 'resolute' },
143 dpkg_packages: [{ name: 'libapache2-mod-php8.5', version: '8.5.4-0ubuntu1.5', source: 'php8.5' }], config_path: '/etc/php/8.5/apache2' }), { distro: 'ubuntu:26.10' });
144 assert.equal(r.verdict, 'patched');
145 assert.match(notes(r), /Distribution list is set to Ubuntu 26\.10/);
146 });
147
148 test('#10 a manual release that contradicts the release the version string belongs to is not used', () => {
149 const r = run('X-Powered-By: PHP/8.5.9-0ubuntu2.1', { distro: 'ubuntu:26.04' });
150 assert.notEqual(r.verdict, 'vulnerable');
151 assert.match(notes(r), /Ubuntu 26\.10/);
152 });
153
154 test('#11 "Upstream build" is not applied to a PHP_VERSION with a distribution revision', () => {
155 const r = run(probe({ php_version: '8.1.2-1ubuntu2.28', sapi: 'apache2handler', os_release: JAMMY,
156 dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }], config_path: '/etc/php/8.1/apache2' }), { distro: 'upstream' });
157 assert.equal(r.verdict, 'patched');
158 assert.match(notes(r), /Upstream build/);
159 // Positive control: an upstream choice for a plain version is still used.
160 assert.equal(run('X-Powered-By: PHP/8.3.6', { distro: 'upstream' }).verdict, 'vulnerable');
161 });
162
163 test('#12 a Debian derivative with an unknown codename takes the release from the installed package', () => {
164 const r = run(probe({ php_version: '8.2.26', sapi: 'apache2handler', os_release: { ID: 'linuxmint', ID_LIKE: 'debian', VERSION_ID: '6', VERSION_CODENAME: 'faye', PRETTY_NAME: 'LMDE 6 (faye)' },
165 dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.35-1~deb12u1', source: 'php8.2' }], config_path: '/etc/php/8.2/apache2' }));
166 assert.equal(r.verdict, 'restart');
167 assert.match(fact(r, 'Distribution'), /Debian 12/);
168 // Even with no release at all, an installed package with the upstream fix shows a missed restart.
169 const sid = run(probe({ php_version: '8.2.26', sapi: 'apache2handler', os_release: { ID: 'debian', VERSION_CODENAME: 'sid' },
170 dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.35-1', source: 'php8.2' }], config_path: '/etc/php/8.2/apache2' }));
171 assert.equal(sid.verdict, 'restart');
172 });
173
174 // ── Tester 2 ────────────────────────────────────────────────────────
175
176 test('#13 every X-Powered-By header in a curl -IL paste is judged', () => {
177 const chain = 'HTTP/1.1 301 Moved Permanently\nServer: nginx\nX-Powered-By: PHP/8.4.27\nLocation: https://www.example.com/\n\nHTTP/1.1 200 OK\nServer: nginx\nX-Powered-By: PHP/8.4.20\n';
178 for (const r of both(chain)) assert.equal(r.verdict, 'vulnerable');
179 assert.equal(run('HTTP/1.1 200 OK\nX-Powered-By: PHP/8.2.35\nX-Powered-By: PHP/8.1.20\n').verdict, 'vulnerable');
180 assert.match(notes(run(chain)), /2 separate outputs/);
181 // The same version on every hop is one output.
182 assert.equal(parseInput('HTTP/1.1 301\nX-Powered-By: PHP/8.2.35\n\nHTTP/1.1 200 OK\nX-Powered-By: PHP/8.2.35\n').parts, undefined);
183 });
184
185 test('#14 every -v line is judged, so an old php-fpm is not hidden by a new CLI', () => {
186 const r = run('$ php -v\nPHP 8.4.27 (cli) (built: Oct 21 2026 10:00:00) (NTS)\n$ php-fpm8.4 -v\nPHP 8.4.20 (fpm-fcgi) (built: Jun 10 2026 10:00:00)\n');
187 assert.equal(r.verdict, 'vulnerable');
188 assert.match(reasons(r), /8\.4\.20.*fpm-fcgi/);
189 });
190
191 test('#15 with the server script, other installed builds next to the probe PHP are judged too', () => {
192 const sury = '8.4.27-1+0~20261021.1+debian12~1.gbp1a2b3c';
193 const text = probe({ php_version: '8.4.27', sapi: 'apache2handler', config_path: '/etc/php/8.4/apache2', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' },
194 dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: sury, source: 'php8.4' }, { name: 'php8.2-fpm', version: '8.2.29-1~deb12u1', source: 'php8.2' }] })
195 + server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'os|VERSION_CODENAME|bookworm', `pkg|dpkg|libapache2-mod-php8.4|${sury}|php8.4`,
196 'pkg|dpkg|php8.2-fpm|8.2.29-1~deb12u1|php8.2', 'proc|apache2|5|ok', 'proc|php-fpm8.2|6|ok']);
197 for (const r of both(text)) assert.equal(r.verdict, 'vulnerable');
198 const r = run(text);
199 assert.match(reasons(r), /php8\.2-fpm 8\.2\.29-1~deb12u1/);
200 assert.match(actionText(r), /restart php8\.2-fpm/);
201 assert.equal(fact(r, 'Other installed PHP builds'), 'php8.2-fpm 8.2.29-1~deb12u1');
202 });
203
204 test('#16 a RHEL container probe is not judged by a same-OS host changelog', () => {
205 const r = run(probe({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', container: true, open_basedir: false, uname_release: '5.14.0-427.el9_4.x86_64', os_release: ROCKY94, dpkg_packages: null })
206 + server(['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...ROCKY_RPMS, 'proc|php-fpm|6|ok']));
207 assert.equal(r.verdict, 'unknown');
208 assert.match(notes(r), /docker exec/);
209 const ubuntu = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', container: true, os_release: NOBLE, dpkg_packages: [], config_path: '/etc/php/8.3/fpm' })
210 + server([...NOBLE_HOST, 'proc|php-fpm8.3|4|ok']));
211 assert.equal(ubuntu.verdict, 'unknown');
212 });
213
214 test('#17 a probe and a server script that disagree about the packages are not Patched', () => {
215 const pkgs = [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }, { name: 'php8.3-common', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }];
216 const text = probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: { ...NOBLE, ID_LIKE: 'debian' }, dpkg_packages: pkgs })
217 + server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble',
218 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.5|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.5|php8.3', 'proc|php-fpm8.3|3|ok']);
219 for (const r of both(text)) assert.equal(r.verdict, 'unknown');
220 assert.match(notes(run(text)), /different times/);
221 });
222
223 test('#18 hidepid without root: missing proc lines are not "no PHP running"', () => {
224 const rocky = server(['tool|php2shell-server-check|1.0.0', 'env|root|0', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...ROCKY_RPMS, 'webuser_proc|unknown']);
225 const noble = server(['tool|php2shell-server-check|1.0.0', 'env|root|0', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble',
226 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3', 'webuser_proc|unknown']);
227 assert.equal(run(rocky).verdict, 'unknown');
228 assert.equal(run(noble).verdict, 'unknown');
229 assert.equal(run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: NOBLE, dpkg_packages: null }) + noble).verdict, 'unknown');
230 // Positive controls: as root, with the process checked, it is Patched. A CLI-only build has no service process
231 // that could keep an old build loaded, so a non-root run does not make it Undetermined.
232 assert.equal(run(server([...NOBLE_HOST, 'proc|php-fpm8.3|3|ok'])).verdict, 'patched');
233 assert.equal(run(server(['env|root|0', 'os|ID|ubuntu', 'os|VERSION_ID|24.04', 'pkg|dpkg|php8.3-cli|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3'])).verdict, 'patched');
234 });
235
236 test('#19 a server script block cut off before its END line is not Patched', () => {
237 const head = ['tool|php2shell-server-check|1.0.0', 'env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...ROCKY_RPMS];
238 for (const lines of [head, [...head, 'proc|php-fpm|6|sta']]) {
239 for (const r of both(server(lines, false))) {
240 assert.equal(r.verdict, 'unknown');
241 assert.ok(r.notes.some((n) => /END/.test(n)));
242 }
243 }
244 // A status other than ok or stale is a process that was not checked, even with the END line.
245 const bad = run(server(['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...ROCKY_RPMS, 'proc|php-fpm|6|']));
246 assert.equal(bad.verdict, 'unknown');
247 // Positive control: the same block with its END line and a checked process is Patched.
248 assert.equal(run(server([...head, 'proc|php-fpm|6|ok'])).verdict, 'patched');
249 });
250
251 test('#20 a php-fpm or apache2 that no installed Debian package can run is not checked, so not Patched', () => {
252 const docker = run(server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy',
253 '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']));
254 assert.equal(docker.verdict, 'unknown');
255 assert.match(reasons(docker), /belong to none of the PHP builds/);
256 const apache = run(server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12',
257 'pkg|dpkg|php8.2-cli|8.2.35-1~deb12u1|php8.2', 'pkg|dpkg|php8.2-common|8.2.35-1~deb12u1|php8.2', 'proc|apache2|9|ok']));
258 assert.equal(apache.verdict, 'unknown');
259 });
260
261 test('#21 a running php-fpm8.3 with no php8.3 package is not checked, so not Patched', () => {
262 const base = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm8.1|5|ok'];
263 assert.equal(run(server([...base, 'proc|php-fpm8.3|4|ok'])).verdict, 'unknown');
264 const stale = run(server([...base, 'proc|php-fpm8.3|4|stale']));
265 assert.equal(stale.verdict, 'restart');
266 assert.match(reasons(stale), /php-fpm8\.3/);
267 assert.doesNotMatch(actionText(stale), /restart php8\.1-fpm/, 'the restart command names no unrelated service');
268 // Positive control: the same server without the extra process is Patched.
269 assert.equal(run(server(base)).verdict, 'patched');
270 });
271
272 test('#22 several outputs in one paste are all judged', () => {
273 const deb12 = { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' };
274 const pools = probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: deb12, dpkg_packages: [] })
275 + probe({ php_version: '8.1.33', sapi: 'fpm-fcgi', config_path: '/etc/php/8.1/fpm', os_release: deb12, dpkg_packages: [] });
276 const hosts = server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok'])
277 + server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.29-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok']);
278 const probeHeader = probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: deb12, dpkg_packages: [] }) + 'HTTP/1.1 200 OK\nX-Powered-By: PHP/8.4.20\n';
279 const headerCli = 'X-Powered-By: PHP/8.4.27\n\n$ php -v\nPHP 8.4.20 (cli) (built: Oct 20 2026 12:00:00) (NTS)\n';
280 for (const text of [pools, hosts, probeHeader, headerCli]) {
281 for (const r of both(text)) assert.equal(r.verdict, 'vulnerable');
282 }
283 // The same output pasted twice is one output, and the server script's own indented text is never read as a header.
284 const one = probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: deb12, dpkg_packages: [] });
285 assert.equal(parseInput(one + one).parts, undefined);
286 assert.equal(run(one + one).verdict, 'patched');
287 assert.equal(parseInput(server(['env|root|1']) + server(['env|root|1'])).parts, undefined);
288 assert.equal(parseInput(`${server(['env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2'])} X-Powered-By: PHP/8.2.20\n`).parts, undefined);
289 });
290
291 test('#23 a container probe with no OS of its own is not judged by an Ubuntu host', () => {
292 const r = run(probe({ php_version: '8.3.6', sapi: 'frankenphp', config_path: '/etc/frankenphp', container: true, open_basedir: false, os_release: null, dpkg_packages: null })
293 + server([...NOBLE_HOST, 'proc|php-fpm8.3|3|ok']));
294 assert.notEqual(r.verdict, 'patched');
295 assert.doesNotMatch(r.reasons.filter((x) => !/^Server script output/.test(x)).join(' '), /backported the fixes/);
296 });
297
298 test('#24 php-fpm -v shows the binary on disk, so Patched carries the restart caveat', () => {
299 for (const text of ['PHP 8.1.2-1ubuntu2.28 (fpm-fcgi) (built: Oct 20 2026 12:00:00)\nCopyright (c) The PHP Group\n', 'PHP 8.2.35 (fpm-fcgi) (built: Oct 20 2026 12:00:00)\n']) {
300 const [en, ko] = both(text);
301 assert.equal(en.verdict, 'patched');
302 assert.match(reasons(en), /binary installed on disk/);
303 assert.match(reasons(ko), /디스크에 설치된 바이너리/);
304 }
305 });
306
307 test('#25 php -v with CLI in capitals or extra spaces still gets the CLI note', () => {
308 for (const text of ['PHP 8.2.35 (CLI) (built: Oct 21 2026 10:00:00) (NTS)\n', 'PHP 8.2.35 (cli) (built: Oct 21 2026 10:00:00) (NTS)\n']) {
309 const r = run(text);
310 assert.equal(r.verdict, 'patched');
311 assert.match(notes(r), /command-line PHP/, text);
312 assert.equal(r.exposure.level, 'info', text);
313 }
314 });
315
316 test('#26 Red Hat\'s "Not affected" for plain php covers only the branch Red Hat ships', () => {
317 const r = run(server(['env|root|1', 'os|ID|centos', 'os|ID_LIKE|rhel fedora', 'os|VERSION_ID|7', 'pkg|rpm|php-common|8.1.20-1.el7|', 'pkg|rpm|php-fpm|8.1.20-1.el7|',
318 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|0', 'changelog|php-fpm|CVE-2026-109618|1', 'changelog|php-fpm|CVE-2026-109619|0', 'proc|php-fpm|3|ok']));
319 assert.equal(r.verdict, 'unknown');
320 assert.doesNotMatch(reasons(r), /not affected/);
321 });
322
323 test('#27 Alpine _rc and _pre builds are pre-releases', () => {
324 assert.equal(parsePhpVersion('8.5.12_rc1-r0').pre, 'rc1');
325 assert.equal(parsePhpVersion('8.5.0_pre1').pre, 'pre1');
326 assert.equal(parsePhpVersion('8.5.12_p1-r0').pre, '');
327 assert.equal(run(server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.22.0', 'pkg|apk|php85|8.5.12_rc1-r0|', 'pkg|apk|php85-fpm|8.5.12_rc1-r0|', 'proc|php-fpm85|2|ok'])).verdict, 'unknown');
328 assert.equal(run('8.2.35_rc1').verdict, 'unknown');
329 // Positive control: the release itself on Alpine is Patched, and php-fpm85 belongs to the php85 build.
330 assert.equal(run(server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.22.0', 'pkg|apk|php85|8.5.12-r0|', 'pkg|apk|php85-fpm|8.5.12-r0|', 'proc|php-fpm85|2|ok'])).verdict, 'patched');
331 });
332
333 test('#28 dropped host output does not turn a RHEL container probe into an upstream build', () => {
334 const container = { php_version: '8.0.30', sapi: 'fpm-fcgi', config_path: '/etc', container: true, open_basedir: false, os_release: { ID: 'rocky', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.6' }, dpkg_packages: null };
335 const alone = run(probe(container));
336 assert.equal(alone.verdict, 'unknown');
337 const ubuntuHost = run(probe(container) + server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'proc|php-fpm8.3|3|ok']));
338 assert.equal(ubuntuHost.verdict, 'unknown');
339 assert.doesNotMatch(notes(ubuntuHost), /found no PHP installed with rpm/);
340 const olderHost = run(probe(container) + server(['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', 'pkg|rpm|php-common|8.0.30-8.el9_8.1|',
341 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|1']));
342 assert.equal(olderHost.verdict, 'unknown');
343 });
344
345 test('#29 a bare 8.0.30 header, with or without a RHEL Server header, is Undetermined', () => {
346 for (const text of ['X-Powered-By: PHP/8.0.30\n', 'PHP 8.0.30 (cli) (built: Aug 3 2026 17:13:08) ( NTS gcc x86_64 )', 'HTTP/1.1 200 OK\nServer: Apache/2.4.57 (Rocky Linux)\nX-Powered-By: PHP/8.0.30\n', 'X-Powered-By: PHP/7.4.33\n']) {
347 for (const r of both(text)) assert.equal(r.verdict, 'unknown', text);
348 }
349 const rocky = run('HTTP/1.1 200 OK\nServer: Apache/2.4.57 (Rocky Linux)\nX-Powered-By: PHP/8.2.13\n');
350 assert.equal(rocky.verdict, 'unknown', 'the Server header names a RHEL rebuild');
351 assert.match(fact(rocky, 'Distribution'), /Server header/);
352 });
353
354 test('#30 a Remi collection of an EOL branch is judged by its changelog when it records both fixes', () => {
355 const block = server(['env|root|1', 'os|ID|centos', 'os|VERSION_ID|7', 'pkg|rpm|php74-php-common|7.4.33-18.el7.remi|', 'pkg|rpm|php74-php-fpm|7.4.33-18.el7.remi|',
356 'changelog|php74-php-common|CVE-2026-109618|1', 'changelog|php74-php-common|CVE-2026-109619|1', 'changelog|php74-php-fpm|CVE-2026-109618|1', 'changelog|php74-php-fpm|CVE-2026-109619|1', 'proc|php-fpm|4|ok']);
357 for (const r of both(block)) assert.equal(r.verdict, 'patched');
358 assert.match(reasons(run(block)), /Remi/);
359 const withProbe = run(probe({ php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/opt/remi/php74', os_release: { ID: 'centos', VERSION_ID: '7' }, dpkg_packages: null }) + block);
360 assert.equal(withProbe.verdict, 'patched');
361 // Positive control: without the changelog entries a Remi EOL build is still judged by upstream.
362 const none = run(server(['env|root|1', 'os|ID|centos', 'os|VERSION_ID|7', 'pkg|rpm|php74-php-common|7.4.33-18.el7.remi|',
363 'changelog|php74-php-common|CVE-2026-109618|1', 'changelog|php74-php-common|CVE-2026-109619|0']));
364 assert.equal(none.verdict, 'vulnerable');
365 });