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/contract.test.mjs (20632B)

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