test/findings.test.mjs (29222B)
1 // Regression tests for the verdict-engine review findings. Each test reproduces the
2 // reviewer's scenario against site/data/upstream.json and a frozen copy of site/data/distro.json
3 // (fixtures/distro-snapshot.json), so data refreshes do not move the verdicts.
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, evaluateMitigations } from '../site/lib/judge.js';
9 import { parsePhpVersion } from '../site/lib/version.js';
10 import { MESSAGES } from '../site/lib/messages.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 function selfcheck(fields) {
16 const report = {
17 tool: 'php2shell-selfcheck',
18 tool_version: '1.0.0',
19 schema: 1,
20 php_version: '8.4.26',
21 sapi: 'apache2handler',
22 os_release: {},
23 dpkg_packages: [],
24 extensions: { session: true, opcache: true },
25 ini: { memory_limit: '128M', 'session.upload_progress.enabled': '1', 'session.auto_start': '0', expose_php: '1' },
26 self_deleted: true,
27 ...fields,
28 };
29 return `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify(report, null, 2)}\n-----END PHP2SHELL SELFCHECK-----\n`;
30 }
31 const servercheck = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${lines.join('\n')}\n-----END PHP2SHELL SERVERCHECK-----\n`;
32 const run = (text, options = {}) => judge(parseInput(text, { lang: options.lang }), upstream, distro, options);
33 const all = (r) => [...r.reasons, ...r.notes].join(' ');
34 const fact = (r, label) => r.facts.find((f) => f.label === label)?.value;
35 const actionText = (r) => r.actions.map((a) => `${a.text} ${a.code || ''}`).join(' ');
36
37 const UBUNTU_2404 = { ID: 'ubuntu', VERSION_ID: '24.04', VERSION_CODENAME: 'noble', PRETTY_NAME: 'Ubuntu 24.04.3 LTS' };
38 const UBUNTU_2204 = { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' };
39
40 // ── #1 deb.sury.org builds ───────────────────────────────────────────
41
42 test('#1 a deb.sury.org revision in PHP_VERSION is judged by upstream, never as an Ubuntu backport', () => {
43 const header = run('X-Powered-By: PHP/8.3.20-1+ubuntu22.04.1+deb.sury.org+1');
44 assert.equal(header.verdict, 'vulnerable');
45 assert.match(header.notes.join(' '), /sury/);
46 assert.doesNotMatch(fact(header, 'Distribution'), /24\.04/);
47 assert.doesNotMatch(all(header), /backported/);
48
49 // deb.sury.org builds of end-of-life branches may carry backported fixes (round 3), which only the changelog shows:
50 // without it they are Undetermined, never Patched, and still never an Ubuntu backport.
51 const cli = run('PHP 7.4.33-1+ubuntu20.04.1+deb.sury.org+1 (cli) (built: Nov 2 2026)');
52 assert.equal(cli.verdict, 'unknown');
53 assert.match(all(cli), /changelog/);
54 assert.doesNotMatch(cli.notes.join(' '), /Ubuntu Pro/);
55
56 const old = run('X-Powered-By: PHP/5.6.40-1+ubuntu20.04.1+deb.sury.org+1');
57 assert.equal(old.verdict, 'unknown');
58 assert.doesNotMatch(fact(old, 'Distribution'), /14\.04/);
59 assert.doesNotMatch(all(old), /Ubuntu has backported/);
60
61 const probe = run(selfcheck({ php_version: '8.3.20-1+ubuntu24.04.1+deb.sury.org+1', sapi: 'fpm-fcgi', os_release: UBUNTU_2404, dpkg_packages: null }));
62 assert.equal(probe.verdict, 'vulnerable');
63 });
64
65 test('#1 general guard: a build newer upstream than the release package is not that release\'s build', () => {
66 const r = run(selfcheck({ php_version: '8.3.20-1ubuntu1', os_release: UBUNTU_2404, dpkg_packages: null }));
67 assert.notEqual(r.verdict, 'patched');
68 assert.equal(r.verdict, 'vulnerable');
69 });
70
71 // ── #2 RHEL changelog must belong to the running PHP ─────────────────
72
73 const ALMA9 = ['os|ID|almalinux', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.6', 'env|root|1'];
74
75 test('#2 RHEL: a patched base php-common does not vouch for a remi PHP that is running', () => {
76 const block = servercheck([...ALMA9,
77 'pkg|rpm|php-common|8.2.30-1.module_el9.6.0+120+abcdef12|',
78 'pkg|rpm|php83-php-common|8.3.20-1.el9.remi|',
79 'pkg|rpm|php83-php-fpm|8.3.20-1.el9.remi|',
80 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2',
81 'changelog|php83-php-common|CVE-2026-109618|0', 'changelog|php83-php-common|CVE-2026-109619|0',
82 'proc|php-fpm|6|ok']);
83 const probe = selfcheck({ php_version: '8.3.20', sapi: 'fpm-fcgi', config_path: '/etc/opt/remi/php83', os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.6' } });
84 const r = run(probe + block);
85 assert.equal(r.verdict, 'vulnerable');
86 assert.match(r.notes.join(' '), /remi/);
87 assert.doesNotMatch(r.reasons.join(' '), /records fixes for both/);
88 });
89
90 test('#2 RHEL: a changelog from another build is not evidence for the PHP that ran the probe', () => {
91 const block = servercheck(['os|ID|cloudlinux', 'os|ID_LIKE|rhel fedora centos', 'os|VERSION_ID|9.4', 'env|root|1',
92 'pkg|rpm|php-common|8.2.30-1.module_el9.4.0+1+abc|',
93 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2']);
94 const r = run(selfcheck({ php_version: '8.3.20', sapi: 'litespeed', os_release: { ID: 'cloudlinux', VERSION_ID: '9.4' } }) + block);
95 assert.equal(r.verdict, 'unknown');
96 assert.match(r.reasons.join(' '), /php-common 8\.2\.30/);
97 });
98
99 test('#2 RHEL: the changelog of the package that matches the running PHP still counts', () => {
100 // The probe and the server script ran on the same system, so they report the same os-release.
101 const block = servercheck([...ALMA9, 'pkg|rpm|php-common|8.2.13-1.module_el9.4.0+20844+7c41d8d0|', 'pkg|rpm|php-fpm|8.2.13-1.module_el9.4.0+20844+7c41d8d0|',
102 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|1',
103 'changelog|php-fpm|CVE-2026-109618|2', 'changelog|php-fpm|CVE-2026-109619|1', 'proc|php-fpm|4|ok']);
104 const r = run(selfcheck({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.6' }, dpkg_packages: null }) + block);
105 assert.equal(r.verdict, 'patched');
106 });
107
108 test('#2 server script only: each RHEL build is judged with its own changelog', () => {
109 const r = run(servercheck([...ALMA9,
110 'pkg|rpm|php-common|8.2.30-1.module_el9.6.0+120+abcdef12|',
111 'pkg|rpm|php83-php-common|8.3.20-1.el9.remi|',
112 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2',
113 'changelog|php83-php-common|CVE-2026-109618|0', 'changelog|php83-php-common|CVE-2026-109619|0']));
114 assert.equal(r.verdict, 'vulnerable');
115 assert.match(r.reasons.join(' '), /php83-php-common/);
116 });
117
118 test('#2 Debian changelog fallback only uses the changelog of the running build', () => {
119 const block = servercheck(['os|ID|debian', 'os|VERSION_ID|11', 'env|root|1',
120 'pkg|dpkg|php7.4-common|7.4.33-1+deb11u5|php7.4',
121 'pkg|dpkg|php8.1-common|8.1.30-1+deb11u1|php8.1',
122 'changelog|php7.4-common|CVE-2026-109618|0', 'changelog|php7.4-common|CVE-2026-109619|0',
123 'changelog|php8.1-common|CVE-2026-109618|1', 'changelog|php8.1-common|CVE-2026-109619|1']);
124 // The probe reads the same package list as the server script on the same system.
125 const pkgs = [{ name: 'php7.4-common', version: '7.4.33-1+deb11u5', source: 'php7.4' }, { name: 'php8.1-common', version: '8.1.30-1+deb11u1', source: 'php8.1' }];
126 const r = run(selfcheck({ php_version: '7.4.33', os_release: { ID: 'debian', VERSION_ID: '11' }, dpkg_packages: pkgs }) + block);
127 assert.equal(r.verdict, 'unknown');
128 // The php8.1 build is judged on its own changelog; the running 7.4 PHP is not credited with it.
129 assert.match(r.reasons[0], /^Running PHP 7\.4\.33/);
130 assert.doesNotMatch(r.reasons[0], /treated as fixed/);
131 });
132
133 // ── #3 inferred Ubuntu release must match the version string ─────────
134
135 test('#3 EOL Ubuntu interim releases are not mapped onto the LTS package', () => {
136 for (const text of ['X-Powered-By: PHP/8.1.7-1ubuntu3.6', 'X-Powered-By: PHP/8.1.12-1ubuntu4.3', 'X-Powered-By: PHP/7.4.9-1ubuntu1.3', 'PHP 7.4.16-1ubuntu2.3 (cli) (built: Mar 1 2022)']) {
137 const r = run(text);
138 assert.equal(r.verdict, 'unknown', text);
139 assert.doesNotMatch(fact(r, 'Distribution'), /22\.04|20\.04/, text);
140 assert.doesNotMatch(r.notes.join(' '), /Ubuntu Pro/, text);
141 }
142 assert.equal(run('X-Powered-By: PHP/8.1.2-1ubuntu2.28').verdict, 'patched');
143 });
144
145 test('#3 picking the LTS by hand for an interim build does not make it Patched', () => {
146 const r = run('X-Powered-By: PHP/8.1.7-1ubuntu3.6', { distro: 'ubuntu:22.04' });
147 assert.equal(r.verdict, 'vulnerable');
148 });
149
150 // ── #4 a PHP that is not the installed distribution package ──────────
151
152 test('#4 a panel PHP is not judged by an unrelated distribution package of the same branch', () => {
153 const pkgs = [{ name: 'php8.3-cli', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }, { name: 'php8.3-common', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }];
154 const panel = run(selfcheck({ php_version: '8.3.12', sapi: 'fpm-fcgi', config_path: '/www/server/php/83/etc', os_release: UBUNTU_2404, dpkg_packages: pkgs }));
155 assert.equal(panel.verdict, 'vulnerable');
156 const noPath = run(selfcheck({ php_version: '8.3.12', sapi: 'fpm-fcgi', os_release: UBUNTU_2404, dpkg_packages: pkgs }));
157 assert.equal(noPath.verdict, 'vulnerable');
158 });
159
160 test('#4 a container probe is not judged by host packages', () => {
161 const host = servercheck(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-cli|8.3.6-0ubuntu0.24.04.13|php8.3']);
162 const r = run(selfcheck({ php_version: '8.3.12', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12' } }) + host);
163 assert.equal(r.verdict, 'vulnerable');
164 assert.match(r.notes.join(' '), /container/);
165
166 const sameOs = servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1', 'pkg|dpkg|libapache2-mod-php8.2|8.2.35-1~deb12u1|php8.2']);
167 const c = run(selfcheck({ php_version: '8.2.30', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12' } }) + sameOs);
168 assert.equal(c.verdict, 'vulnerable');
169 assert.ok(c.actions.length > 0);
170 assert.match(actionText(c), /Dockerfile/);
171 });
172
173 // ── #5 same version before and after an Ubuntu fix ──────────────────
174
175 test('#5 Ubuntu 24.04 without a package list is Undetermined, not Vulnerable', () => {
176 const manual = run('X-Powered-By: PHP/8.3.6', { distro: 'ubuntu:24.04' });
177 assert.equal(manual.verdict, 'unknown');
178 assert.match(manual.reasons.join(' '), /server script/);
179
180 const probe = run(selfcheck({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: UBUNTU_2404, dpkg_packages: null }));
181 assert.equal(probe.verdict, 'unknown');
182 assert.doesNotMatch(probe.notes.join(' '), /upstream build/);
183
184 const resolute = run(selfcheck({ php_version: '8.5.4', os_release: { ID: 'ubuntu', VERSION_ID: '26.04' }, dpkg_packages: null }));
185 assert.equal(resolute.verdict, 'unknown');
186
187 const bare = run('X-Powered-By: PHP/8.3.6');
188 assert.equal(bare.verdict, 'unknown');
189 assert.match(bare.reasons.join(' '), /Ubuntu 24\.04/);
190 });
191
192 test('#5 a version Ubuntu 24.04 never shipped falls back to upstream', () => {
193 assert.equal(run('X-Powered-By: PHP/8.3.12', { distro: 'ubuntu:24.04' }).verdict, 'vulnerable');
194 // Without the distribution, 8.3.12 may also be a build of the RHEL 9 php:8.3 module stream, whose fix keeps the
195 // upstream version (round 3): Undetermined. A branch with no such stream fix is still judged by upstream.
196 const bare = run('X-Powered-By: PHP/8.3.12');
197 assert.equal(bare.verdict, 'unknown');
198 assert.match(bare.reasons.join(' '), /RHEL 9/);
199 assert.equal(run('X-Powered-By: PHP/8.4.20').verdict, 'vulnerable');
200 });
201
202 // ── #6 every installed branch is judged ─────────────────────────────
203
204 test('#6 server script only: a vulnerable second branch is not hidden by a patched first one', () => {
205 const r = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1',
206 'pkg|dpkg|php8.2-common|8.2.35-1~deb12u1|php8.2',
207 'pkg|dpkg|php8.4-common|8.4.20-1+0~20260301.30+debian12~1.gbp1a2b3c|php8.4',
208 'pkg|dpkg|php8.4-fpm|8.4.20-1+0~20260301.30+debian12~1.gbp1a2b3c|php8.4',
209 'proc|php-fpm8.4|5|ok']));
210 assert.equal(r.verdict, 'vulnerable');
211 assert.match(r.reasons.join(' '), /php8\.4/);
212 assert.match(r.reasons.join(' '), /php8\.2/);
213 });
214
215 // ── #7 -dev snapshots ───────────────────────────────────────────────
216
217 test('#7 -dev snapshots are pre-releases', () => {
218 assert.equal(parsePhpVersion('8.5.12-dev').pre, 'dev');
219 assert.equal(parsePhpVersion('8.5.12-dev').revision, '');
220 assert.equal(run('PHP 8.5.12-dev (cli) (built: Oct 20 2026)').verdict, 'unknown');
221 assert.equal(run('X-Powered-By: PHP/8.4.27-dev').verdict, 'unknown');
222 });
223
224 // ── #9 the running PHP was never checked ─────────────────────────────
225
226 test('#9 server script without root: the installed package alone is not Patched', () => {
227 const r = run(servercheck(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'env|root|0',
228 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1',
229 'proc|unreadable|9|unknown']));
230 assert.equal(r.verdict, 'unknown');
231 assert.match(r.reasons.join(' '), /running PHP could not be checked/);
232 assert.ok(fact(r, 'Installed package version'));
233 assert.equal(fact(r, 'PHP version'), undefined);
234 });
235
236 test('#9 server script as root with the processes checked is Patched', () => {
237 const r = run(servercheck(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'env|root|1',
238 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1', 'proc|apache2|3|ok']));
239 assert.equal(r.verdict, 'patched');
240 const none = run(servercheck(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'env|root|1', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1']));
241 assert.equal(none.verdict, 'patched');
242 assert.match(none.reasons.join(' '), /No running PHP process/);
243 });
244
245 // ── #10 Ubuntu 24.04+: the loaded build cannot be seen ──────────────
246
247 test('#10 Ubuntu 24.04 probe: Patched says the loaded build could not be confirmed', () => {
248 const probe = selfcheck({ php_version: '8.3.6', sapi: 'fpm-fcgi', os_release: UBUNTU_2404, dpkg_packages: [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }] });
249 const r = run(probe);
250 assert.equal(r.verdict, 'patched');
251 assert.match(r.reasons.join(' '), /server script/);
252 // The server script on the same system lists the same package as the probe.
253 const host = ['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3'];
254 const checked = run(probe + servercheck([...host, 'proc|php-fpm8.3|4|ok']));
255 assert.equal(checked.verdict, 'patched');
256 assert.doesNotMatch(checked.reasons.join(' '), /server script/);
257 const stale = run(probe + servercheck([...host, 'proc|php-fpm8.3|4|stale']));
258 assert.equal(stale.verdict, 'restart');
259 });
260
261 // ── #11 Ubuntu 14.04 +dfsg ──────────────────────────────────────────
262
263 test('#11 Ubuntu 14.04: PHP_VERSION without +dfsg compares equal to the fixed package', () => {
264 assert.equal(run('X-Powered-By: PHP/5.5.9-1ubuntu4.29+esm18').verdict, 'patched');
265 const os = { ID: 'ubuntu', VERSION_ID: '14.04' };
266 const pkg = [{ name: 'libapache2-mod-php5', version: '5.5.9+dfsg-1ubuntu4.29+esm18', source: 'php5' }];
267 assert.equal(run(selfcheck({ php_version: '5.5.9-1ubuntu4.29+esm18', os_release: os, dpkg_packages: pkg })).verdict, 'patched');
268 assert.equal(run(selfcheck({ php_version: '5.5.9-1ubuntu4.29+esm17', os_release: os, dpkg_packages: pkg })).verdict, 'restart');
269 });
270
271 // ── #12 a running build that has the fix ────────────────────────────
272
273 test('#12 a fixed running build with a newer package waiting is Patched, with a note', () => {
274 const r = run(selfcheck({
275 php_version: '8.1.2-1ubuntu2.28', os_release: UBUNTU_2204,
276 dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.29', source: 'php8.1' }],
277 }));
278 assert.equal(r.verdict, 'patched');
279 assert.match(r.notes.join(' '), /8\.1\.2-1ubuntu2\.29/);
280
281 const fpm = run(selfcheck({ php_version: '8.4.27', sapi: 'fpm-fcgi' }) + servercheck(['env|root|1', 'proc|php-fpm8.4|4|stale']));
282 assert.equal(fpm.verdict, 'patched');
283 assert.match(fpm.notes.join(' '), /php-fpm8\.4/);
284
285 // Stale processes of another SAPI may be an older PHP: the server still needs a restart.
286 const other = run(selfcheck({ php_version: '8.4.27', sapi: 'apache2handler' }) + servercheck(['env|root|1', 'proc|php-fpm8.4|4|stale']));
287 assert.equal(other.verdict, 'restart');
288 assert.ok(other.actions.length > 0, 'a restart verdict always has a next step');
289 });
290
291 // ── #22 #23 mitigation advice ────────────────────────────────────────
292
293 test('#22 the memory_limit advice never points to RLIMIT_AS', () => {
294 const [mem] = evaluateMitigations({ memory_limit: '128M' }, null, null);
295 assert.doesNotMatch(mem.note, /OS-level memory limit/);
296 assert.match(mem.note, /cgroup/);
297 assert.match(mem.note, /Do not use RLIMIT_AS or RLIMIT_DATA/);
298 const [ko] = evaluateMitigations({ memory_limit: '128M' }, null, null, false, 'ko');
299 assert.match(ko.note, /RLIMIT_AS/);
300 });
301
302 test('#23 without probe settings the mitigation action names the mitigations', () => {
303 const r = run('X-Powered-By: PHP/8.4.26');
304 assert.equal(r.mitigations.length, 0);
305 assert.doesNotMatch(actionText(r), /listed in this result/);
306 assert.match(actionText(r), /memory_limit=-1/);
307 assert.match(actionText(r), /session\.upload_progress\.enabled=0/);
308
309 const none = run('hello world');
310 assert.doesNotMatch(actionText(none), /mitigation|compromise/);
311
312 const pending = run(selfcheck({ php_version: '8.4.26', ini: null, os_release: { ID: 'debian', VERSION_CODENAME: 'forky' }, dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: '8.4.26-1', source: 'php8.4' }] }));
313 assert.equal(pending.verdict, 'pending');
314 // With a known SAPI and layout, the mitigations come as a configuration snippet that names them.
315 assert.match(actionText(pending), /php_admin_value memory_limit -1/);
316 assert.match(actionText(pending), /php_admin_flag session\.upload_progress\.enabled off/);
317 });
318
319 // ── #24 other distributions ─────────────────────────────────────────
320
321 test('#24 other distributions get a note to check their own advisories', () => {
322 // SUSE backports into its own PHP packages, so a version below the fix is Undetermined there (round 3).
323 const r = run(selfcheck({ php_version: '8.2.20', os_release: { ID: 'opensuse-leap', ID_LIKE: 'suse opensuse', VERSION_ID: '15.6' } }));
324 assert.equal(r.verdict, 'unknown');
325 assert.match(all(r), /opensuse-leap/);
326 assert.match(all(r), /advisories/);
327 const arch = run(selfcheck({ php_version: '8.2.20', os_release: { ID: 'arch' } }));
328 assert.equal(arch.verdict, 'vulnerable');
329 assert.match(arch.notes.join(' '), /\(arch\).*advisories/);
330 });
331
332 // ── #26 Ubuntu and Debian derivatives ────────────────────────────────
333
334 test('#26 Ubuntu derivatives are judged with their parent release', () => {
335 const mint = run(selfcheck({
336 php_version: '8.1.2-1ubuntu2.28',
337 os_release: { ID: 'linuxmint', ID_LIKE: 'ubuntu debian', VERSION_ID: '21.3', VERSION_CODENAME: 'virginia' },
338 dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
339 }));
340 assert.equal(mint.verdict, 'patched');
341 const pop = run(servercheck(['os|ID|pop', 'os|ID_LIKE|ubuntu debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', 'env|root|1',
342 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1', 'proc|apache2|2|ok']));
343 assert.equal(pop.verdict, 'patched');
344 });
345
346 // ── #27 legacy redhat-release ───────────────────────────────────────
347
348 test('#27 the probe\'s LEGACY_REDHAT-RELEASE key is recognised', () => {
349 const r = run(selfcheck({ php_version: '5.6.25', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' } }));
350 assert.equal(r.verdict, 'unknown');
351 assert.match(fact(r, 'Distribution'), /CentOS release 6\.10/);
352 assert.ok(r.advisories.some((a) => /RHEL 6/.test(a.label)));
353 });
354
355 // ── #28 container actions ───────────────────────────────────────────
356
357 test('#28 a distribution PHP in a container is told to update its packages, not FROM php:', () => {
358 const r = run(selfcheck({
359 php_version: '8.3.6', container: true, config_path: '/etc/php/8.3/apache2', os_release: UBUNTU_2404,
360 dpkg_packages: [{ name: 'libapache2-mod-php8.3', version: '8.3.6-0ubuntu0.24.04.4', source: 'php8.3' }],
361 }));
362 assert.equal(r.verdict, 'vulnerable');
363 assert.doesNotMatch(actionText(r), /FROM php:/);
364 assert.match(actionText(r), /apt-get/);
365 assert.match(actionText(r), /rebuild/);
366 });
367
368 test('#28 a restart verdict in a container says to recreate the container', () => {
369 const r = run(selfcheck({
370 php_version: '8.1.2-1ubuntu2.20', container: true, config_path: '/etc/php/8.1/apache2', os_release: UBUNTU_2204,
371 dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
372 }));
373 assert.equal(r.verdict, 'restart');
374 assert.match(actionText(r), /container/);
375 });
376
377 // ── #38 EOL wording ─────────────────────────────────────────────────
378
379 test('#38 the EOL reason says it is about upstream builds', () => {
380 const r = run('8.1.30');
381 assert.equal(r.verdict, 'vulnerable');
382 assert.match(r.reasons.join(' '), /upstream build/);
383 });
384
385 // ── #39 #40 RHEL details ────────────────────────────────────────────
386
387 const CENTOS7 = ['os|ID|centos', 'os|ID_LIKE|rhel fedora', 'os|VERSION_ID|7', 'env|root|1', 'pkg|rpm|php-common|5.4.16-48.el7|', 'pkg|rpm|php|5.4.16-48.el7|'];
388
389 test('#39 RHEL: a CVE Red Hat lists as not affected does not count as missing', () => {
390 const r = run(servercheck([...CENTOS7, 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|0', 'proc|httpd|5|ok']));
391 assert.equal(r.verdict, 'patched');
392 assert.match(r.reasons.join(' '), /not affected/);
393 assert.doesNotMatch(r.reasons.join(' '), /has not been updated/);
394 });
395
396 test('#40 RHEL 6 and 7 get yum and service commands', () => {
397 const r7 = run(servercheck([...CENTOS7, 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0', 'proc|httpd|5|ok']));
398 assert.match(actionText(r7), /yum/);
399 assert.doesNotMatch(actionText(r7), /dnf/);
400 const r6 = run(selfcheck({ php_version: '5.3.3', sapi: 'apache2handler', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' } }));
401 assert.match(actionText(r6), /service httpd restart/);
402 assert.doesNotMatch(actionText(r6), /systemctl/);
403 const r9 = run(servercheck([...ALMA9, 'pkg|rpm|php-common|8.2.13-1.module_el9.4.0+1+ab|', 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0']));
404 assert.match(actionText(r9), /dnf/);
405 });
406
407 // ── #41 pre-releases and newer branches ─────────────────────────────
408
409 test('#41 pre-releases and newer branches get the right reason and action', () => {
410 const rc = run('8.3.12RC1');
411 assert.equal(rc.verdict, 'unknown');
412 assert.match(rc.reasons.join(' '), /pre-release/);
413 assert.doesNotMatch(rc.reasons.join(' '), /development version/);
414 const next = run('8.6.0');
415 assert.equal(next.verdict, 'unknown');
416 assert.match(next.reasons.join(' '), /newer than/);
417 assert.doesNotMatch(actionText(next), /fixed release of the 8\.6 branch/);
418 const eol = run('7.4.33');
419 assert.doesNotMatch(actionText(eol), /fixed release of the 7\.4 branch/);
420 });
421
422 // ── #43 findings wording ────────────────────────────────────────────
423
424 test('#43 crash and web-account findings do not over-attribute', () => {
425 const r = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok', 'log|crash|2', 'webuser_proc|2']));
426 const crash = r.findings.find((f) => /crash/.test(f.text));
427 assert.match(crash.text, /web server or PHP/);
428 const proc = r.findings.find((f) => /process/.test(f.text));
429 assert.equal(proc.level, 'warn');
430 assert.match(proc.text, /nobody/);
431 });
432
433 // ── Contracts: env|root, unknown counts, uname_release, open_basedir ─
434
435 test('contract: unknown counts and non-root runs are never reported as clean', () => {
436 const unknown = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok',
437 'log|oom|unknown', 'log|crash|0', 'log|bigsession|unknown', 'webuser_proc|0']));
438 assert.ok(!unknown.findings.some((f) => f.level === 'ok'), 'no "nothing suspicious" line');
439 assert.ok(unknown.findings.some((f) => f.level === 'info' && /could not be read/.test(f.text)));
440 assert.ok(!unknown.findings.some((f) => /isolate/.test(f.text)), 'no incident advice without a finding');
441
442 const notRoot = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|0', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2',
443 'log|oom|0', 'log|crash|0', 'log|bigsession|0', 'webuser_proc|0']));
444 assert.ok(!notRoot.findings.some((f) => f.level === 'ok'));
445 assert.ok(notRoot.findings.some((f) => /root/.test(f.text)));
446
447 const clean = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok',
448 'log|oom|0', 'log|crash|0', 'log|bigsession|0', 'webuser_proc|0', 'webroot|recent_php|0']));
449 assert.deepEqual(clean.findings.map((f) => f.level), ['ok']);
450
451 const webroot = run(servercheck(['env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'webroot|recent_php|unknown']));
452 assert.ok(!webroot.findings.some((f) => f.level === 'ok'));
453 });
454
455 test('contract: a RHEL kernel in uname_release identifies the RHEL family when os-release is empty', () => {
456 const r = run(selfcheck({ php_version: '8.2.13', sapi: 'fpm-fcgi', os_release: [], open_basedir: true, uname_release: '5.14.0-427.el9_4.x86_64', dpkg_packages: null }));
457 assert.equal(r.verdict, 'unknown');
458 assert.match(fact(r, 'Distribution'), /RHEL family 9/);
459 assert.match(r.notes.join(' '), /open_basedir/);
460 // A container reports the host kernel, so it is not used there.
461 const c = run(selfcheck({ php_version: '8.2.13', container: true, config_path: '/usr/local/etc/php', os_release: [], uname_release: '5.14.0-427.el9_4.x86_64' }));
462 assert.equal(c.verdict, 'vulnerable');
463 });
464
465 test('contract: with open_basedir hiding the OS, a version below the fix is Undetermined', () => {
466 const r = run(selfcheck({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', os_release: [], open_basedir: true, uname_release: '', dpkg_packages: null }));
467 assert.equal(r.verdict, 'unknown');
468 assert.match(r.reasons.join(' '), /open_basedir/);
469 assert.equal(run(selfcheck({ php_version: '8.2.35', os_release: [], open_basedir: true, uname_release: '' })).verdict, 'patched');
470 });
471
472 // ── Korean catalog rules ────────────────────────────────────────────
473
474 // The word the Korean text must never use for a check, written as escapes so that it appears nowhere in the repository.
475 const BANNED_KO = /\uD310\uC815/;
476
477 test('the Korean catalog says 확인 for a check, and names the page controls', () => {
478 for (const [key, msg] of Object.entries(MESSAGES.ko)) {
479 const text = typeof msg === 'function' ? msg({ n: 2, items: 'x' }) : msg;
480 assert.doesNotMatch(text, BANNED_KO, key);
481 }
482 assert.match(MESSAGES.ko['reason.debReleaseUnknown'], /배포판 목록에서 릴리스를 고른 뒤 결과확인을 다시 누르세요/);
483 assert.match(MESSAGES.en['reason.debReleaseUnknown'], /Distribution list.*Run check/);
484 assert.match(MESSAGES.ko['finding.incident'], /KISA/);
485 });