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/findings.test.mjs (29222B)

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