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/i18n.test.mjs (15373B)

      1 import { test } from 'node:test';
      2 import assert from 'node:assert/strict';
      3 import { readFileSync } from 'node:fs';
      4 import { LANGS, MESSAGES, translator } from '../site/lib/messages.js';
      5 import { parseInput } from '../site/lib/parse.js';
      6 import {
      7   judge, VERDICT_TITLES, VERDICT_LABELS, verdictTitles, verdictLabels, sapiLabel, sapiExposure, evaluateMitigations,
      8 } from '../site/lib/judge.js';
      9 
     10 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
     11 const distro = JSON.parse(readFileSync(new URL('./fixtures/distro.json', import.meta.url), 'utf8'));
     12 
     13 const HANGUL = /[가-힣]/;
     14 const LEFTOVER = /\{\w+\}|undefined|NaN/;
     15 const placeholders = (s) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
     16 
     17 const KO_TITLES = {
     18   patched: '수정이 적용된 PHP입니다',
     19   vulnerable: '취약한 PHP입니다',
     20   pending: '취약하며, 배포판의 수정 패키지가 아직 없습니다',
     21   restart: '설치된 PHP는 수정됐지만 실행 중인 PHP는 여전히 취약합니다',
     22   unknown: '판단할 근거가 부족합니다',
     23 };
     24 const KO_LABELS = {
     25   patched: '패치됨',
     26   vulnerable: '취약',
     27   pending: '수정 대기',
     28   restart: '재시작 필요',
     29   unknown: '판단 불가',
     30 };
     31 
     32 function selfcheck(fields) {
     33   const report = {
     34     tool: 'php2shell-selfcheck',
     35     tool_version: '1.0.0',
     36     schema: 1,
     37     php_version: '8.4.26',
     38     sapi: 'apache2handler',
     39     os_release: {},
     40     dpkg_packages: [],
     41     extensions: { session: true, opcache: true },
     42     ini: {
     43       memory_limit: '128M',
     44       'session.upload_progress.enabled': '1',
     45       'session.auto_start': '0',
     46       expose_php: '1',
     47     },
     48     self_deleted: true,
     49     ...fields,
     50   };
     51   return `php2shell-selfcheck 1.0.0\n\n-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify(report, null, 2)}\n-----END PHP2SHELL SELFCHECK-----\n`;
     52 }
     53 const servercheck = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${lines.join('\n')}\n-----END PHP2SHELL SERVERCHECK-----`;
     54 
     55 const run = (text, lang, options = {}) => judge(parseInput(text, { lang }), upstream, distro, { ...options, lang });
     56 
     57 test('the supported languages are English and Korean', () => {
     58   assert.deepEqual(LANGS, ['en', 'ko']);
     59   assert.deepEqual(Object.keys(MESSAGES).sort(), [...LANGS].sort());
     60 });
     61 
     62 test('English and Korean have the same keys, each of the same kind', () => {
     63   const en = Object.keys(MESSAGES.en).sort();
     64   const ko = Object.keys(MESSAGES.ko).sort();
     65   assert.deepEqual(ko.filter((k) => !en.includes(k)), [], 'keys only in ko');
     66   assert.deepEqual(en.filter((k) => !ko.includes(k)), [], 'keys only in en');
     67   for (const key of en) {
     68     assert.equal(typeof MESSAGES.ko[key], typeof MESSAGES.en[key], `kind of ${key}`);
     69     assert.ok(['string', 'function'].includes(typeof MESSAGES.en[key]), `kind of ${key}`);
     70   }
     71 });
     72 
     73 test('string messages use the same placeholders in both languages', () => {
     74   for (const [key, en] of Object.entries(MESSAGES.en)) {
     75     if (typeof en !== 'string') continue;
     76     assert.deepEqual(placeholders(MESSAGES.ko[key]), placeholders(en), `placeholders of ${key}`);
     77   }
     78 });
     79 
     80 test('function messages produce clean text for singular and plural counts', () => {
     81   for (const lang of LANGS) {
     82     const t = translator(lang);
     83     for (const [key, msg] of Object.entries(MESSAGES[lang])) {
     84       if (typeof msg !== 'function') continue;
     85       for (const n of [1, 3, '1', '4']) {
     86         const out = t(key, { n });
     87         assert.equal(typeof out, 'string');
     88         assert.ok(out.includes(String(n)), `${lang} ${key} shows the count ${n}`);
     89         assert.doesNotMatch(out, LEFTOVER, `${lang} ${key}`);
     90       }
     91     }
     92   }
     93   const en = translator('en');
     94   assert.match(en('finding.recentPhp', { n: 1 }), /^Found 1 PHP file changed/);
     95   assert.match(en('finding.recentPhp', { n: 2 }), /^Found 2 PHP files changed/);
     96   assert.match(en('parse.procUnreadable', { n: '1' }), /check 1 PHP process for/);
     97   assert.match(en('parse.procUnreadable', { n: '4' }), /check 4 PHP processes for/);
     98 });
     99 
    100 test('the translator falls back to English and rejects unknown keys and missing values', () => {
    101   for (const lang of ['xx', 'kr', '', undefined, null]) {
    102     assert.equal(translator(lang)('verdict.title.patched'), 'This PHP has the fix', `lang ${lang}`);
    103   }
    104   assert.equal(translator('ko')('verdict.title.patched'), KO_TITLES.patched);
    105   assert.throws(() => translator('ko')('no.such.key'), { message: 'Missing message: no.such.key' });
    106   assert.throws(() => translator('xx')('no.such.key'), { message: 'Missing message: no.such.key' });
    107   assert.throws(() => translator('ko')('toString'), { message: 'Missing message: toString' });
    108   assert.throws(() => translator('en')('reason.eol'), /\{branch\}/);
    109 
    110   MESSAGES.en['test.onlyInEnglish'] = 'Only {what}';
    111   try {
    112     assert.equal(translator('ko')('test.onlyInEnglish', { what: 'English' }), 'Only English');
    113   } finally {
    114     delete MESSAGES.en['test.onlyInEnglish'];
    115   }
    116 });
    117 
    118 test('placeholder values are inserted as they are, without being read as placeholders', () => {
    119   assert.equal(translator('en')('reason.eol', { branch: '{x}' }), 'PHP {x} has reached end of life (EOL) and has no official fix release, so an upstream build of it is vulnerable. Only a distribution package with the fixes backported can have them.');
    120   assert.equal(translator('ko')('advisory.fixedIn', { version: '8.2.35-1~deb12u1' }), '8.2.35-1~deb12u1에서 수정됨');
    121 });
    122 
    123 test('verdict titles and labels are available in both languages', () => {
    124   assert.deepEqual(verdictTitles('ko'), KO_TITLES);
    125   assert.deepEqual(verdictLabels('ko'), KO_LABELS);
    126   assert.deepEqual(verdictTitles('en'), VERDICT_TITLES);
    127   assert.deepEqual(verdictLabels('en'), VERDICT_LABELS);
    128   assert.deepEqual(verdictTitles(), VERDICT_TITLES);
    129   assert.deepEqual(verdictLabels('xx'), VERDICT_LABELS);
    130   assert.notEqual(verdictLabels('en'), VERDICT_LABELS, 'a fresh object every call');
    131 });
    132 
    133 test('exported helpers keep English by default and accept a language', () => {
    134   assert.equal(sapiLabel('cli'), 'Command line (CLI) (cli)');
    135   assert.equal(sapiLabel('cli', 'ko'), '명령줄(CLI) (cli)');
    136   assert.equal(sapiLabel('fpm-fcgi', 'ko'), 'PHP-FPM (fpm-fcgi)');
    137   assert.equal(sapiExposure('apache2handler').level, sapiExposure('apache2handler', 'ko').level);
    138   assert.match(sapiExposure('embed', 'ko').text, /embed/);
    139   assert.match(sapiExposure('embed', 'ko').text, HANGUL);
    140   const ini = { memory_limit: null, 'session.upload_progress.enabled': '' };
    141   const en = evaluateMitigations(ini, null, null);
    142   const ko = evaluateMitigations(ini, null, null, false, 'ko');
    143   assert.deepEqual(en.map((m) => m.value), ['(not set)', '(empty = Off)', '(not set)']);
    144   assert.deepEqual(ko.map((m) => m.value), ['(설정 안 됨)', '(빈 값 = Off)', '(설정 안 됨)']);
    145   assert.deepEqual(ko.map((m) => [m.name, m.status]), en.map((m) => [m.name, m.status]));
    146 });
    147 
    148 test('parseInput writes its notes in the requested language', () => {
    149   assert.match(parseInput('hello world').notes[0], /^No PHP version was found/);
    150   assert.match(parseInput('hello world', { lang: 'ko' }).notes[0], /^PHP 버전을 찾지 못했습니다/);
    151   assert.match(parseInput('PHP 8.3.6 (cli) (built: Mar 1 2026) (NTS)', { lang: 'ko' }).notes[0], /명령줄/);
    152   assert.deepEqual(parseInput('8.2.26', { lang: 'ko' }), parseInput('8.2.26'));
    153 });
    154 
    155 const SCENARIOS = {
    156   'upstream vulnerable (Docker)': [selfcheck({ php_version: '8.4.26', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '13' } })],
    157   'upstream patched': [selfcheck({ php_version: '8.4.27' })],
    158   'Ubuntu backport': [selfcheck({
    159     php_version: '8.1.2-1ubuntu2.28',
    160     os_release: { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' },
    161     dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
    162   })],
    163   'Ubuntu restart': [selfcheck({
    164     php_version: '8.1.2-1ubuntu2.20',
    165     os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
    166     dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
    167   })],
    168   'Ubuntu vulnerable from a header': ['X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4'],
    169   'Ubuntu Pro': [selfcheck({
    170     php_version: '7.4.3-4ubuntu2.29+esm5',
    171     os_release: { ID: 'ubuntu', VERSION_ID: '20.04' },
    172     dpkg_packages: [{ name: 'libapache2-mod-php7.4', version: '7.4.3-4ubuntu2.29+esm5', source: 'php7.4' }],
    173   })],
    174   'Debian pending': [selfcheck({
    175     php_version: '8.4.26',
    176     os_release: { ID: 'debian', VERSION_CODENAME: 'forky' },
    177     dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: '8.4.26-1', source: 'php8.4' }],
    178   })],
    179   'Debian release unknown': [servercheck(['os|ID|debian', 'pkg|dpkg|php8.2-common|8.2.26-1~deb12u1|php8.2'])],
    180   'Sury build': [selfcheck({
    181     php_version: '8.3.14',
    182     os_release: { ID: 'debian', VERSION_ID: '12' },
    183     dpkg_packages: [{ name: 'libapache2-mod-php8.3', version: '8.3.14-1+0~20241122.43+debian12~1.gbp9f1e3a', source: 'php8.3' }],
    184   })],
    185   'EOL with a backport branch': ['8.1.30'],
    186   'PHP 5.3': ['5.3.29'],
    187   'PHP 8.6 RC': ['8.6.0RC3'],
    188   'CLI': ['PHP 8.3.6 (cli) (built: Mar 1 2026) (NTS)'],
    189   'RHEL changelog fixed': [servercheck(['os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.6', 'pkg|rpm|php-common|8.0.30-8.el9_8.1|', 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2'])],
    190   'RHEL changelog missing': [servercheck(['os|ID|rocky', 'os|VERSION_ID|9.6', 'pkg|rpm|php-common|8.0.30-8.el9_8.1|', 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|0'])],
    191   'server script findings': [servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'webuser_proc|2', 'log|oom|5', 'log|crash|1', 'log|bigsession|0', 'webroot|recent_php|3', 'proc|unreadable|4|unknown', 'proc|php-fpm8.2|812|stale'])],
    192   'server script clean': [servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'webuser_proc|0', 'log|oom|0'])],
    193   'mitigations unpatched': [selfcheck({ php_version: '8.5.11', ini: { memory_limit: '-1', 'session.upload_progress.enabled': '', 'session.auto_start': '1', fatal_error_backtraces: '1', max_input_nesting_level: '64', post_max_size: '8M', expose_php: '' } })],
    194   'mitigations patched': [selfcheck({ php_version: '8.5.12', ini: { memory_limit: '-1', 'session.upload_progress.enabled': '1', 'session.auto_start': '1', fatal_error_backtraces: '1', expose_php: 'On' } })],
    195   'manual distribution': ['X-Powered-By: PHP/8.1.2-1ubuntu2.28', { distro: 'ubuntu:22.04' }],
    196   'unknown input': ['hello world'],
    197 };
    198 
    199 for (const [name, [text, options]] of Object.entries(SCENARIOS)) {
    200   test(`Korean result: ${name}`, () => {
    201     const en = run(text, 'en', options);
    202     const ko = run(text, 'ko', options);
    203     assert.equal(ko.verdict, en.verdict, 'same verdict in both languages');
    204     assert.equal(ko.title, KO_TITLES[ko.verdict]);
    205     assert.equal(en.title, VERDICT_TITLES[en.verdict]);
    206 
    207     // Same shape: the same number of entries everywhere, the same data fields.
    208     for (const k of ['reasons', 'notes', 'actions', 'facts', 'mitigations', 'findings', 'advisories']) {
    209       assert.equal(ko[k].length, en[k].length, `${k} count`);
    210     }
    211     assert.deepEqual(ko.actions.map((a) => Boolean(a.code)), en.actions.map((a) => Boolean(a.code)));
    212     assert.deepEqual(ko.mitigations.map((m) => [m.name, m.status]), en.mitigations.map((m) => [m.name, m.status]));
    213     assert.deepEqual(ko.findings.map((f) => f.level), en.findings.map((f) => f.level));
    214     assert.equal(ko.exposure?.level, en.exposure?.level);
    215     assert.equal(ko.eol, en.eol);
    216 
    217     const prose = [
    218       ...ko.reasons, ...ko.notes, ...ko.actions.map((a) => a.text), ...ko.mitigations.map((m) => m.note),
    219       ...ko.findings.map((f) => f.text), ...(ko.exposure ? [ko.exposure.text] : []),
    220     ];
    221     assert.ok(prose.length > 0);
    222     for (const s of prose) assert.match(s, HANGUL, s);
    223 
    224     const everything = [
    225       ko.title, ...prose, ...ko.facts.flatMap((f) => [f.label, f.value]), ...ko.advisories.flatMap((a) => [a.label, a.value]),
    226       ...ko.mitigations.map((m) => String(m.value)), ...ko.actions.map((a) => a.code || ''),
    227     ];
    228     for (const s of everything) assert.doesNotMatch(s, LEFTOVER, s);
    229   });
    230 }
    231 
    232 test('Korean results keep data values and commands as they are', () => {
    233   const vulnerable = run(SCENARIOS['Ubuntu vulnerable from a header'][0], 'ko');
    234   assert.match(vulnerable.reasons.join(' '), /8\.3\.6-0ubuntu0\.24\.04\.13/);
    235   assert.ok(vulnerable.actions.some((a) => /^sudo apt-get update\nsudo apt-get install --only-upgrade \$\(dpkg -l 'php8\.3-\*'/.test(a.code || '')));
    236   assert.ok(vulnerable.actions.some((a) => a.code === 'sudo systemctl restart apache2   # Apache mod_php\nsudo systemctl restart php8.3-fpm   # PHP-FPM'));
    237 
    238   const restart = run(SCENARIOS['Ubuntu restart'][0], 'ko');
    239   assert.ok(restart.actions.some((a) => a.code === 'sudo systemctl restart apache2'));
    240   assert.match(restart.reasons[0], /8\.1\.2-1ubuntu2\.28.*8\.1\.2-1ubuntu2\.20/);
    241 
    242   const eol = run('8.1.30', 'ko');
    243   assert.match(eol.notes.join(' '), /PHP-8\.1-CVE-2026-109618-109619/);
    244   assert.match(eol.notes.join(' '), /백포트/);
    245 
    246   const rhel = run(SCENARIOS['RHEL changelog fixed'][0], 'ko');
    247   assert.ok(rhel.advisories.some((a) => /RHSA-2026:87151/.test(a.value)));
    248   assert.equal(rhel.facts.find((f) => f.label === '배포판').value, 'RHEL 9 계열');
    249 
    250   const inferred = run(servercheck(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1', 'proc|apache2|1|stale']), 'ko');
    251   assert.equal(inferred.facts.find((f) => f.label === 'SAPI').value, 'Apache mod_php (apache2handler) (실행 중인 프로세스로 추정)');
    252 
    253   const pending = run(SCENARIOS['Debian pending'][0], 'ko');
    254   assert.deepEqual(pending.advisories.map((a) => a.value), ['수정 대기', '수정 대기']);
    255 });
    256 
    257 test('English and Korean give the same commands, and English keeps the raw Red Hat fix states', () => {
    258   const en = run(SCENARIOS['Ubuntu vulnerable from a header'][0], 'en');
    259   const ko = run(SCENARIOS['Ubuntu vulnerable from a header'][0], 'ko');
    260   assert.deepEqual(ko.actions.map((a) => a.code || null), en.actions.map((a) => a.code || null));
    261   assert.ok(en.actions.some((a) => a.code === 'sudo systemctl restart apache2   # Apache mod_php\nsudo systemctl restart php8.3-fpm   # PHP-FPM'));
    262   const t = translator('en');
    263   for (const [state, key] of [['Affected', 'rhelState.affected'], ['Under investigation', 'rhelState.underInvestigation'], ['Fix deferred', 'rhelState.fixDeferred'], ['Will not fix', 'rhelState.willNotFix'], ['Not affected', 'rhelState.notAffected'], ['Out of support scope', 'rhelState.outOfSupportScope']]) {
    264     assert.equal(t(key), state);
    265   }
    266 });
    267 
    268 test('Korean mitigation notes never present a mitigation as a fix', () => {
    269   const unpatched = run(SCENARIOS['mitigations unpatched'][0], 'ko');
    270   const patched = run(SCENARIOS['mitigations patched'][0], 'ko');
    271   for (const r of [unpatched, patched]) {
    272     for (const s of [...r.mitigations.map((m) => m.note), ...r.actions.map((a) => a.text)]) {
    273       assert.doesNotMatch(s, /안전/, s);
    274     }
    275   }
    276   assert.ok(unpatched.actions.some((a) => /패치를 대신하지 않습니다/.test(a.text)));
    277   assert.ok(patched.mitigations.every((m) => m.status !== 'warn'));
    278 });