php2shell-scanner.git

php2shell-scanner

~ Check PHP2Shell (CVE-2026-109618, CVE-2026-109619) from inside your server: distribution backports, missed restarts and interim mitigations, verdicts computed in the browser.
git clone https://github.com/debuman/php2shell-scanner.git

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 });