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 });