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/retest.test.mjs (28000B)

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