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/judge.test.mjs (12093B)

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