test/contract.test.mjs (20632B)
1 // Regression tests for the engine side of the server script, PHP probe and page contract: what the script and the
2 // probe now emit (env|root, changelog lines, unknown counts, os_release null, open_basedir, the legacy release keys)
3 // and what the page reads from a result (r.family, string facts and advisories). Runs against site/data/upstream.json
4 // and a frozen copy of site/data/distro.json (fixtures/distro-snapshot.json), so data refreshes do not move the verdicts.
5 import { test } from 'node:test';
6 import assert from 'node:assert/strict';
7 import { readFileSync } from 'node:fs';
8 import { parseInput } from '../site/lib/parse.js';
9 import { judge, sapiExposure } from '../site/lib/judge.js';
10
11 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
12 const distro = JSON.parse(readFileSync(new URL('./fixtures/distro-snapshot.json', import.meta.url), 'utf8'));
13
14 const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
15 const server = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${['tool|php2shell-server-check|1.0.0', ...lines].join('\n')}\n-----END PHP2SHELL SERVERCHECK-----\n`;
16 const run = (text, options = {}, data = distro) => judge(parseInput(text, { lang: options.lang }), upstream, data, options);
17 const both = (text, options = {}) => ['en', 'ko'].map((lang) => run(text, { ...options, lang }));
18 const reasons = (r) => r.reasons.join(' ');
19 const notes = (r) => r.notes.join(' ');
20 const fact = (r, label) => r.facts.find((f) => f.label === label)?.value;
21 const actionText = (r) => r.actions.map((a) => `${a.text} ${a.code || ''}`).join(' ');
22
23 const ROCKY94 = ['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4'];
24 const NOBLE = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble'];
25 const MODULE = '8.2.13-1.module+el9.4.0+20827+3f2b0b48';
26 const CVES = ['CVE-2026-109618', 'CVE-2026-109619'];
27 const logged = (pkg, n) => CVES.map((cve) => `changelog|${pkg}|${cve}|${n}`);
28
29 // ── Changelog lines ─────────────────────────────────────────────────
30
31 test('a package with no changelog lines was not checked, which is never a count of 0', () => {
32 // php-fpm, the package that runs, is another build than the php-common whose changelog was read.
33 const mixed = server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, 'pkg|rpm|php-fpm|8.2.13-1.module+el9.2.0+19000+aaaaaaaa|', ...logged('php-common', 1), 'proc|php-fpm|4|ok']);
34 for (const r of both(mixed)) {
35 assert.equal(r.verdict, 'unknown');
36 assert.match(reasons(r), /php-fpm 8\.2\.13-1\.module\+el9\.2\.0/);
37 }
38 assert.match(reasons(run(mixed)), /was not checked/);
39 assert.doesNotMatch(reasons(run(mixed)), /has no record of a fix/);
40 // Positive control: a package of exactly the same build carries the same changelog.
41 const same = server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, ...logged('php-common', 1), 'proc|php-fpm|4|ok']);
42 assert.equal(run(same).verdict, 'patched');
43
44 // No changelog lines at all: not checked, not "not updated yet".
45 const none = run(server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, 'proc|php-fpm|4|ok']));
46 assert.equal(none.verdict, 'unknown');
47 assert.match(reasons(none), /was not checked/);
48 assert.doesNotMatch(reasons(none), /has no record|not been updated/);
49
50 // A Debian package without changelog lines is not credited with a fix it was never checked for.
51 const deb = run(server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|11', 'pkg|dpkg|php7.4-fpm|7.4.33-1+deb11u9|php7.4', 'proc|php-fpm7.4|3|ok']));
52 assert.equal(deb.verdict, 'unknown');
53 assert.doesNotMatch(reasons(deb), /treated as fixed|has no record of a fix/);
54 const obs = parseInput(server(['env|root|1', 'pkg|dpkg|php7.4-fpm|7.4.33-1+deb11u9|php7.4']));
55 assert.equal(obs.packages[0].changelog, undefined);
56 });
57
58 test('a changelog count that is missing or not a number is not checked, never 0', () => {
59 for (const lines of [
60 ['changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|'],
61 ['changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|unknown'],
62 ['changelog|php-common|CVE-2026-109618|1'],
63 ]) {
64 const block = server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, ...lines, 'proc|php-fpm|4|ok']);
65 for (const r of both(block)) assert.equal(r.verdict, 'unknown', lines.join(' '));
66 const r = run(block);
67 assert.match(reasons(r), /was not checked for CVE-2026-109619/, lines.join(' '));
68 assert.doesNotMatch(reasons(r), /has no record of a fix/, lines.join(' '));
69 }
70 // A count of 0 is a changelog that was read and has no entry.
71 const zero = run(server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|0']));
72 assert.match(reasons(zero), /has no record of a fix for CVE-2026-109619/);
73 assert.equal(parseInput(server(['pkg|rpm|php-common|8.2.13-1.el9|', 'changelog|php-common|CVE-2026-109619|unknown'])).packages[0].changelog['CVE-2026-109619'], null);
74 });
75
76 // ── Unknown counts ──────────────────────────────────────────────────
77
78 test('an unknown count never gives a "nothing suspicious" (Clear) finding', () => {
79 const base = [...NOBLE, 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'proc|php-fpm8.3|3|ok'];
80 const counts = { 'log|oom': 'logs', 'log|crash': 'logs', 'log|bigsession': 'session files', webuser_proc: 'web server account processes', 'webroot|recent_php': 'web root' };
81 for (const [key, source] of Object.entries(counts)) {
82 const lines = Object.keys(counts).map((k) => `${k}|${k === key ? 'unknown' : 0}`);
83 for (const r of both(server([...base, ...lines]))) {
84 assert.ok(!r.findings.some((f) => f.level === 'ok'), key);
85 assert.ok(r.findings.some((f) => f.level === 'info'), key);
86 }
87 assert.match(run(server([...base, ...lines])).findings.map((f) => f.text).join(' '), new RegExp(`could not be read \\(${source}\\)`), key);
88 }
89 // Several outputs: one clean output does not make the other's unread source "nothing suspicious".
90 const two = run(server([...base, 'webuser_proc|0']) + 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', 'webuser_proc|unknown']));
91 assert.ok(two.parts === undefined && two.findings.length > 0);
92 assert.ok(!two.findings.some((f) => f.level === 'ok'));
93 // Positive control: zero counts from sources that were all read, as root, are Clear, and say what was checked.
94 const clean = run(server([...base, 'webuser_proc|0']));
95 assert.deepEqual(clean.findings.map((f) => f.level), ['ok']);
96 assert.match(clean.findings[0].text, /\(web server account processes\)/);
97 assert.doesNotMatch(clean.findings[0].text, /logs|web root/);
98 assert.match(run(server([...base, 'webuser_proc|0']), { lang: 'ko' }).findings[0].text, /웹 서버 계정 프로세스/);
99 });
100
101 // ── env|root|0 ──────────────────────────────────────────────────────
102
103 test('env|root|0 with no proc lines means the processes were not checked', () => {
104 const nonRoot = ['env|root|0', 'os|ID|ubuntu', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble'];
105 for (const r of both(server([...nonRoot, 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'webuser_proc|unknown']))) {
106 assert.equal(r.verdict, 'unknown');
107 }
108 // A CLI-only build has no web service process, so it stays Patched, but never with "no PHP process was found".
109 const [en, ko] = both(server([...nonRoot, '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']));
110 assert.equal(en.verdict, 'patched');
111 assert.equal(ko.verdict, 'patched');
112 assert.match(reasons(en), /did not run as root, so running processes were not checked/);
113 assert.doesNotMatch(reasons(en), /No running PHP process/);
114 assert.match(reasons(ko), /실행 중인 프로세스는 확인하지 못했습니다/);
115 // Positive control: as root, no proc lines do mean that no PHP process of this build runs.
116 const root = run(server([...NOBLE, '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']));
117 assert.equal(root.verdict, 'patched');
118 assert.match(reasons(root), /No running PHP process of this build was found/);
119 });
120
121 // ── os_release null, open_basedir true ──────────────────────────────
122
123 test('os_release null or open_basedir true never makes an upstream-looking version Vulnerable on RHEL-like or unknown systems', () => {
124 const base = { php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', container: false, uname_release: '', dpkg_packages: null };
125 for (const [fields, pattern] of [
126 [{ os_release: null, open_basedir: false }, /could not read the OS release/],
127 [{ os_release: [], open_basedir: false }, /could not read the OS release/],
128 [{ os_release: null, open_basedir: true, container: null }, /open_basedir/],
129 [{ os_release: null, open_basedir: false, php_version: '8.3.12', uname_release: '6.8.0-45-generic' }, /could not read the OS release/],
130 ]) {
131 const text = probe({ ...base, ...fields });
132 for (const r of both(text)) {
133 assert.equal(r.verdict, 'unknown', JSON.stringify(fields));
134 assert.ok(r.notes.length > 0, 'the hidden OS is explained');
135 }
136 const r = run(text);
137 assert.match(reasons(r), pattern);
138 assert.match(notes(r), /could not read the OS release/);
139 assert.equal(r.family, null);
140 }
141 // A RHEL kernel outside a container still names the family, and the version alone does not settle it.
142 const kernel = run(probe({ ...base, os_release: null, open_basedir: false, uname_release: '5.14.0-427.el9_4.x86_64' }));
143 assert.equal(kernel.verdict, 'unknown');
144 assert.match(fact(kernel, 'Distribution'), /RHEL family 9/);
145 // Positive controls: a fixed upstream release, an official Docker image and a readable Debian release are still judged.
146 assert.equal(run(probe({ ...base, os_release: null, open_basedir: false, php_version: '8.2.35' })).verdict, 'patched');
147 assert.equal(run(probe({ ...base, os_release: null, open_basedir: false, container: true, config_path: '/usr/local/etc/php' })).verdict, 'vulnerable');
148 assert.equal(run(probe({ ...base, php_version: '8.2.26', config_path: '/etc/php/8.2/fpm', os_release: { ID: 'debian', VERSION_ID: '12' }, open_basedir: false })).verdict, 'vulnerable');
149 });
150
151 test('the server script supplies the OS and packages the probe could not read, and the warning goes away', () => {
152 const text = probe({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', container: false, os_release: null, open_basedir: false, uname_release: '', dpkg_packages: null })
153 + server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, ...logged('php-common', 1), ...logged('php-fpm', 1), 'proc|php-fpm|4|ok']);
154 const r = run(text);
155 assert.equal(r.verdict, 'patched');
156 assert.doesNotMatch(notes(r), /could not read the OS release/);
157 assert.equal(r.family, 'rhel');
158 });
159
160 // ── r.family ────────────────────────────────────────────────────────
161
162 test('r.family names the distribution data the verdict rests on, and null for upstream-only verdicts', () => {
163 const cases = [
164 ['debian', 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'])],
165 ['ubuntu', 'X-Powered-By: PHP/8.1.2-1ubuntu2.20'],
166 ['rhel', server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, ...logged('php-common', 1)])],
167 [null, probe({ php_version: '8.3.12', sapi: 'apache2handler', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' }, dpkg_packages: [] })],
168 [null, 'X-Powered-By: PHP/8.3.12'],
169 [null, 'X-Powered-By: PHP/8.3.20-1+ubuntu22.04.1+deb.sury.org+1'],
170 [null, server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.22.0', 'pkg|apk|php84|8.4.20-r0|', 'pkg|apk|php84-fpm|8.4.20-r0|', 'proc|php-fpm84|2|ok'])],
171 [null, server(['env|root|1', 'os|ID|centos', 'os|VERSION_ID|7', 'pkg|rpm|php83-php-common|8.3.20-1.el7.remi|'])],
172 [null, probe({ php_version: '8.2.20', os_release: { ID: 'opensuse-leap', ID_LIKE: 'suse opensuse', VERSION_ID: '15.6' } })],
173 [null, '8.6.0RC3'],
174 [null, 'hello world'],
175 ];
176 for (const [family, text] of cases) {
177 for (const r of both(text)) assert.equal(r.family, family, text);
178 }
179 // Several outputs: the family of the worst one.
180 assert.equal(run('X-Powered-By: PHP/8.3.12\n\nX-Powered-By: PHP/8.1.2-1ubuntu2.28\n').family, null);
181 });
182
183 // ── Strings for the page ────────────────────────────────────────────
184
185 test('facts and advisories values are always strings', () => {
186 const odd = run(probe({ php_version: '8.2.20', sapi: 7, server_software: { name: 'x' }, config_path: 5, os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [], ini: [] }));
187 assert.equal(odd.verdict, 'vulnerable');
188 assert.equal(odd.mitigations.length, 0, 'an empty ini list is no settings');
189 // Advisory data with fields missing never shows "undefined" or "null".
190 const data = JSON.parse(JSON.stringify(distro));
191 data.redhat['9'].advisories.push({ advisory: 'RHSA-2026:99999', cve: 'CVE-2026-109618' });
192 data.redhat['9'].states.push({ cve: 'CVE-2026-109619', state: 'Affected' }, { cve: 'CVE-2026-109619', package: 'php' });
193 const rhel = run(server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, ...logged('php-common', 1)]), {}, data);
194 assert.ok(rhel.advisories.some((a) => a.value === 'RHSA-2026:99999'));
195 for (const r of [odd, rhel, run('X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4'), run('8.1.30'), run('hello world')]) {
196 for (const row of [...r.facts, ...r.advisories]) {
197 assert.equal(typeof row.label, 'string');
198 assert.equal(typeof row.value, 'string');
199 assert.doesNotMatch(row.value, /^(null|undefined)$|\[object |undefined/, JSON.stringify(row));
200 }
201 }
202 assert.equal(fact(odd, 'Web server'), undefined);
203 assert.equal(fact(odd, 'SAPI'), undefined);
204 });
205
206 // ── Texts ───────────────────────────────────────────────────────────
207
208 test('the default exposure text says to treat the SAPI as exposed until it is patched', () => {
209 assert.match(sapiExposure('cgi-fcgi').text, /Treat it as exposed until it is patched\.$/);
210 assert.match(sapiExposure('embed').text, /^The official advisory does not mention embed separately\./);
211 assert.match(sapiExposure('cgi-fcgi', 'ko').text, /패치를 적용할 때까지는 노출된 것으로 간주해야 합니다\.$/);
212 assert.equal(sapiExposure('cgi-fcgi').level, 'medium');
213 });
214
215 test('restart advice uses a full restart, and says a reload or graceful restart keeps the old build only for mod_php', () => {
216 const restart = both(probe({ php_version: '8.1.2-1ubuntu2.20', sapi: 'apache2handler', os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
217 dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }], config_path: '/etc/php/8.1/apache2' }));
218 for (const r of restart) {
219 assert.equal(r.verdict, 'restart');
220 const step = r.actions.find((a) => a.code === 'sudo systemctl restart apache2');
221 assert.ok(step, 'a full restart command, not a reload');
222 }
223 assert.match(actionText(restart[0]), /mod_php.*only a full restart of Apache, not a reload or a graceful restart, loads the new build/);
224 assert.match(actionText(restart[1]), /reload나 graceful 재시작이 아니라 Apache를 완전히 재시작해야/);
225 // PHP-FPM: a reload re-executes the master from disk, so the mod_php sentence is not given.
226 const fpm = run(probe({ php_version: '8.1.2-1ubuntu2.20', sapi: 'fpm-fcgi', os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
227 dpkg_packages: [{ name: 'php8.1-fpm', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }], config_path: '/etc/php/8.1/fpm' }));
228 assert.equal(fpm.actions[0].code, 'sudo systemctl restart php8.1-fpm');
229 assert.doesNotMatch(actionText(fpm), /graceful|keeps the old PHP build/);
230 // Without a known service name, the advice names what started the processes.
231 const generic = run(server(['env|root|1', 'os|ID|ubuntu', 'os|VERSION_ID|22.04', 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm8.1|5|ok', 'proc|php-fpm8.3|4|stale']));
232 assert.equal(generic.verdict, 'restart');
233 assert.match(actionText(generic), /Restart whatever runs these PHP processes \(php-fpm8\.3\)/);
234 // After an update, the restart step names both possible services when the SAPI is not known.
235 assert.match(actionText(run('X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4')), /After the update, restart the service that runs PHP with the command below/);
236 for (const r of [...restart, generic]) {
237 for (const a of r.actions) assert.doesNotMatch(a.code || '', /reload|graceful/);
238 }
239 });
240
241 // ── Legacy release files ────────────────────────────────────────────
242
243 test('the probe\'s LEGACY_REDHAT-RELEASE key (with a hyphen) is recognised', () => {
244 const rhel7 = probe({ php_version: '5.4.16', sapi: 'apache2handler', config_path: '/etc', os_release: { 'LEGACY_REDHAT-RELEASE': 'Red Hat Enterprise Linux Server release 7.9 (Maipo)' }, dpkg_packages: null });
245 const obs = parseInput(rhel7);
246 assert.equal(obs.os.id, 'rhel');
247 assert.equal(obs.os.versionId, '7');
248 assert.equal(obs.osBlocked, false);
249 const r = run(rhel7);
250 assert.equal(r.family, 'rhel');
251 assert.match(fact(r, 'Distribution'), /Red Hat Enterprise Linux Server release 7\.9/);
252 assert.ok(r.advisories.some((a) => /^RHEL 7 /.test(a.label)));
253 // RHEL 7 has systemd; RHEL 6 does not.
254 assert.match(actionText(r), /systemctl restart httpd/);
255 const centos6 = run(probe({ php_version: '5.3.3', sapi: 'apache2handler', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' } }));
256 assert.match(actionText(centos6), /service httpd restart/);
257 assert.equal(parseInput(probe({ php_version: '8.2.13', os_release: { 'LEGACY_REDHAT-RELEASE': 'Rocky Linux release 8.9 (Green Obsidian)' } })).os.id, 'rocky');
258 assert.equal(parseInput(probe({ php_version: '8.2.13', os_release: { LEGACY_REDHAT_RELEASE: 'AlmaLinux release 9.4 (Seafoam Ocelot)' } })).os.id, 'almalinux');
259 // Fedora writes the same file but is not the RHEL family.
260 const fedora = run(probe({ php_version: '8.3.12', os_release: { 'LEGACY_REDHAT-RELEASE': 'Fedora release 40 (Forty)' } }));
261 assert.equal(parseInput(probe({ php_version: '8.3.12', os_release: { 'LEGACY_REDHAT-RELEASE': 'Fedora release 40 (Forty)' } })).os.id, 'fedora');
262 assert.doesNotMatch(fact(fedora, 'Distribution'), /RHEL/);
263 // LEGACY_DEBIAN_VERSION keeps its underscore.
264 assert.deepEqual(parseInput(probe({ php_version: '8.2.26', os_release: { LEGACY_DEBIAN_VERSION: '12.7' } })).os, { id: 'debian', versionId: '12' });
265 });
266
267 test('the probe\'s plain-text notices without markers give no version', () => {
268 for (const text of [
269 'php2shell-selfcheck 1.0.0\nThe file on the server has changed, but PHP ran an older cached copy of it.\nThe cached copy has now been cleared. Load this page again. If this message comes back, upload the file under a new name.\n',
270 'php2shell-selfcheck 1.0.0\nThe probe stopped without collecting anything. Something was sent before it started, so it could not set its own headers. The usual cause is a byte order mark (BOM) or blank space before the opening PHP tag.\nSave the file as UTF-8 without a BOM, with nothing before the opening PHP tag, and upload it again.\n',
271 ]) {
272 const r = run(text);
273 assert.equal(r.verdict, 'unknown');
274 assert.equal(parseInput(text).phpVersion, null);
275 }
276 });