test/judge.test.mjs (12093B)
1 import { test } from 'node:test';
2 import assert from 'node:assert/strict';
3 import { readFileSync } from 'node:fs';
4 import { parseInput } from '../site/lib/parse.js';
5 import { judge, VERDICT_TITLES, VERDICT_LABELS } from '../site/lib/judge.js';
6
7 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
8 const distro = JSON.parse(readFileSync(new URL('./fixtures/distro.json', import.meta.url), 'utf8'));
9
10 function selfcheck(fields) {
11 const report = {
12 tool: 'php2shell-selfcheck',
13 tool_version: '1.0.0',
14 schema: 1,
15 php_version: '8.4.26',
16 sapi: 'apache2handler',
17 os_release: {},
18 dpkg_packages: [],
19 extensions: { session: true, opcache: true },
20 ini: {
21 memory_limit: '128M',
22 'session.upload_progress.enabled': '1',
23 'session.auto_start': '0',
24 expose_php: '1',
25 },
26 self_deleted: true,
27 ...fields,
28 };
29 return `php2shell-selfcheck 1.0.0\n\n-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify(report, null, 2)}\n-----END PHP2SHELL SELFCHECK-----\n`;
30 }
31
32 const run = (text, options) => judge(parseInput(text), upstream, distro, options);
33
34 test('verdict titles and short labels cover the five canonical verdicts', () => {
35 assert.deepEqual(VERDICT_LABELS, {
36 patched: 'Patched',
37 vulnerable: 'Vulnerable',
38 pending: 'Fix pending',
39 restart: 'Restart needed',
40 unknown: 'Undetermined',
41 });
42 assert.deepEqual(Object.keys(VERDICT_TITLES).sort(), Object.keys(VERDICT_LABELS).sort());
43 });
44
45 test('upstream build: older than the fixed release is vulnerable', () => {
46 const r = run(selfcheck({ php_version: '8.4.26', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '13' } }));
47 assert.equal(r.verdict, 'vulnerable');
48 assert.match(r.actions[0].text, /Dockerfile/);
49 assert.equal(r.exposure.level, 'high');
50 });
51
52 test('upstream build: the fixed release or later is patched', () => {
53 assert.equal(run(selfcheck({ php_version: '8.4.27' })).verdict, 'patched');
54 assert.equal(run(selfcheck({ php_version: '8.5.12' })).verdict, 'patched');
55 });
56
57 test('Ubuntu 22.04: patched when the fix is backported, even though the version number is 8.1.2', () => {
58 const r = run(selfcheck({
59 php_version: '8.1.2-1ubuntu2.28',
60 os_release: { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' },
61 dpkg_packages: [
62 { name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' },
63 { name: 'php8.1-common', version: '8.1.2-1ubuntu2.28', source: 'php8.1' },
64 ],
65 }));
66 assert.equal(r.verdict, 'patched');
67 assert.match(r.reasons.join(' '), /backported/);
68 });
69
70 test('Ubuntu 22.04: restart needed when the package is new but the running PHP is old', () => {
71 const r = run(selfcheck({
72 php_version: '8.1.2-1ubuntu2.20',
73 os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
74 dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
75 }));
76 assert.equal(r.verdict, 'restart');
77 assert.ok(r.actions.some((a) => a.code === 'sudo systemctl restart apache2'));
78 });
79
80 test('Ubuntu 22.04: vulnerable when the package is old as well', () => {
81 const r = run(selfcheck({
82 php_version: '8.1.2-1ubuntu2.20',
83 sapi: 'fpm-fcgi',
84 os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
85 dpkg_packages: [{ name: 'php8.1-fpm', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }],
86 }));
87 assert.equal(r.verdict, 'vulnerable');
88 assert.match(r.reasons.join(' '), /8\.1\.2-1ubuntu2\.28/);
89 assert.ok(r.actions.some((a) => a.code === 'sudo systemctl restart php8.1-fpm'));
90 });
91
92 test('a header alone is enough to infer the distribution from the Ubuntu revision', () => {
93 assert.equal(run('HTTP/1.1 200 OK\nServer: Apache/2.4.52 (Ubuntu)\nX-Powered-By: PHP/8.1.2-1ubuntu2.28\n').verdict, 'patched');
94 assert.equal(run('X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4').verdict, 'vulnerable');
95 });
96
97 test('Debian testing: fix pending when there is no fixed package', () => {
98 const r = run(selfcheck({
99 php_version: '8.4.26',
100 os_release: { ID: 'debian', VERSION_CODENAME: 'forky' },
101 dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: '8.4.26-1', source: 'php8.4' }],
102 }));
103 assert.equal(r.verdict, 'pending');
104 });
105
106 test('Debian 12: catches a missed restart by comparing with the installed package, even without a revision in PHP_VERSION', () => {
107 const r = run(selfcheck({
108 php_version: '8.2.26',
109 os_release: { ID: 'debian', VERSION_ID: '12' },
110 dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.35-1~deb12u1', source: 'php8.2' }],
111 }));
112 assert.equal(r.verdict, 'restart');
113 });
114
115 test('Debian 12: vulnerable when not updated', () => {
116 const r = run(selfcheck({
117 php_version: '8.2.26',
118 os_release: { ID: 'debian', VERSION_ID: '12' },
119 dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.26-1~deb12u1', source: 'php8.2' }],
120 }));
121 assert.equal(r.verdict, 'vulnerable');
122 });
123
124 test('a fix available only through Ubuntu Pro is pointed out', () => {
125 const r = run(selfcheck({
126 php_version: '7.4.3-4ubuntu2.29+esm5',
127 os_release: { ID: 'ubuntu', VERSION_ID: '20.04' },
128 dpkg_packages: [{ name: 'libapache2-mod-php7.4', version: '7.4.3-4ubuntu2.29+esm5', source: 'php7.4' }],
129 }));
130 assert.equal(r.verdict, 'patched');
131 assert.match(r.notes.join(' '), /Ubuntu Pro/);
132 });
133
134 test('deb.sury.org builds are checked against upstream', () => {
135 const r = run(selfcheck({
136 php_version: '8.3.14',
137 os_release: { ID: 'debian', VERSION_ID: '12' },
138 dpkg_packages: [{ name: 'libapache2-mod-php8.3', version: '8.3.14-1+0~20241122.43+debian12~1.gbp9f1e3a', source: 'php8.3' }],
139 }));
140 assert.equal(r.verdict, 'vulnerable');
141 assert.match(r.notes.join(' '), /sury/);
142 });
143
144 test('an upstream build of an end-of-life branch is vulnerable and points to the backport branch', () => {
145 const r = run('8.1.30');
146 assert.equal(r.verdict, 'vulnerable');
147 assert.match(r.notes.join(' '), /PHP-8\.1-CVE-2026-109618-109619/);
148 });
149
150 test('PHP older than 5.4 and development versions stay undetermined', () => {
151 assert.equal(run('5.3.29').verdict, 'unknown');
152 assert.equal(run('8.6.0RC3').verdict, 'unknown');
153 });
154
155 test('php -v output gets a CLI warning', () => {
156 // 8.3.20 was never a distribution's backport version (8.3.6 is Ubuntu 24.04's, so it is Undetermined; see findings.test.mjs #5).
157 const r = run('PHP 8.3.20 (cli) (built: Mar 1 2026) (NTS)');
158 assert.equal(r.verdict, 'vulnerable');
159 assert.match(r.notes.join(' '), /command-line/);
160 });
161
162 test('RHEL: patched when the package changelog has both CVEs, undetermined when it does not', () => {
163 const block = (hits) => `-----BEGIN PHP2SHELL SERVERCHECK-----
164 tool|php2shell-server-check|1.0.0
165 os|ID|rocky
166 os|ID_LIKE|rhel centos fedora
167 os|VERSION_ID|9.6
168 pkg|rpm|php-common|8.0.30-8.el9_8.1|
169 changelog|php-common|CVE-2026-109618|${hits}
170 changelog|php-common|CVE-2026-109619|${hits}
171 -----END PHP2SHELL SERVERCHECK-----`;
172 const fixed = run(block(2));
173 assert.equal(fixed.verdict, 'patched');
174 assert.ok(fixed.advisories.some((a) => /RHSA-2026:87151/.test(a.value)));
175 assert.equal(run(block(0)).verdict, 'unknown');
176 });
177
178 test('with only the server script, the SAPI is inferred from processes to pick the restart command', () => {
179 const r = run(`-----BEGIN PHP2SHELL SERVERCHECK-----
180 os|ID|ubuntu
181 os|VERSION_ID|22.04
182 pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1
183 proc|apache2|1|stale
184 -----END PHP2SHELL SERVERCHECK-----`);
185 assert.equal(r.verdict, 'restart');
186 assert.ok(r.actions.some((a) => a.code === 'sudo systemctl restart apache2'));
187 assert.match(r.facts.find((f) => f.label === 'SAPI').value, /inferred/);
188 });
189
190 test('patched when the changelog has both CVEs, even without distribution data', () => {
191 const r = run(`-----BEGIN PHP2SHELL SERVERCHECK-----
192 os|ID|debian
193 os|VERSION_ID|11
194 pkg|dpkg|php7.4-fpm|7.4.33-1+deb11u9|php7.4
195 changelog|php7.4-fpm|CVE-2026-109618|1
196 changelog|php7.4-fpm|CVE-2026-109619|1
197 -----END PHP2SHELL SERVERCHECK-----`);
198 assert.equal(r.verdict, 'patched');
199 assert.match(r.reasons.join(' '), /changelog/);
200 });
201
202 test('restart needed when the server script finds processes using old PHP files', () => {
203 const r = run(`-----BEGIN PHP2SHELL SERVERCHECK-----
204 os|ID|debian
205 os|VERSION_ID|12
206 pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2
207 proc|php-fpm8.2|812|stale
208 proc|php-fpm8.2|813|ok
209 -----END PHP2SHELL SERVERCHECK-----`);
210 assert.equal(r.verdict, 'restart');
211 });
212
213 test('pasting the PHP probe and server script output together uses both', () => {
214 const text = (sapi) => selfcheck({ php_version: '8.2.35', sapi, os_release: { ID: 'debian', VERSION_ID: '12' } })
215 + '\n-----BEGIN PHP2SHELL SERVERCHECK-----\nproc|php-fpm8.2|900|stale\n-----END PHP2SHELL SERVERCHECK-----\n';
216 const obs = parseInput(text('fpm-fcgi'));
217 assert.deepEqual(obs.sources, ['selfcheck', 'servercheck']);
218 // The FPM that ran the probe reports 8.2.35, so what runs has the fix: a newer package only waits for a restart.
219 const fpm = judge(obs, upstream, distro);
220 assert.equal(fpm.verdict, 'patched');
221 assert.match(fpm.notes.join(' '), /php-fpm8\.2/);
222 // Stale FPM processes next to a probe that ran under Apache may be an older build: restart needed.
223 assert.equal(judge(parseInput(text('apache2handler')), upstream, distro).verdict, 'restart');
224 });
225
226 test('interim mitigations are evaluated', () => {
227 const r = run(selfcheck({
228 php_version: '8.5.11',
229 ini: {
230 memory_limit: '-1',
231 'session.upload_progress.enabled': '',
232 'session.auto_start': '0',
233 fatal_error_backtraces: '1',
234 expose_php: '',
235 },
236 }));
237 const byName = Object.fromEntries(r.mitigations.map((m) => [m.name, m.status]));
238 assert.equal(byName.memory_limit, 'ok');
239 assert.equal(byName['session.upload_progress.enabled'], 'ok');
240 assert.equal(byName['session.auto_start'], 'ok');
241 assert.equal(byName.fatal_error_backtraces, 'warn');
242 assert.equal(r.verdict, 'vulnerable', 'interim mitigations do not change the verdict');
243 });
244
245 test('interim mitigations are not recommended on a patched server', () => {
246 const r = run(selfcheck({ php_version: '8.5.12', ini: { memory_limit: '-1', 'session.upload_progress.enabled': '1', 'session.auto_start': '0', fatal_error_backtraces: '1' } }));
247 assert.equal(r.verdict, 'patched');
248 assert.ok(r.mitigations.every((m) => m.status !== 'warn'));
249 assert.match(r.mitigations.find((m) => m.name === 'memory_limit').note, /reverting/);
250 assert.ok(!r.mitigations.some((m) => m.name === 'fatal_error_backtraces'));
251 });
252
253 test('a Docker image of an end-of-life version is told to move to a supported version', () => {
254 const r = run(selfcheck({ php_version: '7.4.33', container: true, config_path: '/usr/local/etc/php' }));
255 assert.equal(r.verdict, 'vulnerable');
256 assert.match(r.actions[0].text, /end of life/);
257 assert.equal(r.actions[0].code, 'FROM php:8.4.27-apache');
258 assert.match(r.actions[0].text, /8\.3\.36/);
259 });
260
261 test('signs of compromise found by the server script are shown', () => {
262 const r = run(`-----BEGIN PHP2SHELL SERVERCHECK-----
263 os|ID|debian
264 os|VERSION_ID|12
265 pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2
266 webuser_proc|2
267 log|oom|5
268 log|crash|0
269 log|bigsession|0
270 proc|unreadable|4|unknown
271 -----END PHP2SHELL SERVERCHECK-----`);
272 const levels = r.findings.map((f) => f.level);
273 // Web-account processes are a "check" item: daemons running as nobody are common (see findings.test.mjs #43).
274 assert.deepEqual(levels, ['warn', 'warn', 'info']);
275 assert.match(r.notes.join(' '), /root/);
276 });
277
278 test('a manually chosen distribution is used', () => {
279 const r = run('X-Powered-By: PHP/8.1.2-1ubuntu2.28', { distro: 'ubuntu:22.04' });
280 assert.equal(r.verdict, 'patched');
281 });
282
283 test('unrecognisable input is undetermined', () => {
284 const r = run('hello world');
285 assert.equal(r.verdict, 'unknown');
286 assert.ok(r.notes.length > 0);
287 });
288
289 test('a failed self-delete is flagged', () => {
290 const r = run(selfcheck({ php_version: '8.4.27', self_deleted: false }));
291 assert.match(r.notes.join(' '), /Delete it yourself/);
292 });