site/app.js (27813B)
1 import { parseInput } from './lib/parse.js';
2 import * as engine from './lib/judge.js';
3 import { UI } from './i18n.js';
4
5 // The Korean page lives at kr/ and points data-root at the site root, so data,
6 // downloads and config resolve the same from either page.
7 const ROOT = new URL(document.documentElement.dataset.root || './', location.href);
8 const LANG = document.documentElement.lang.startsWith('ko') ? 'ko' : 'en';
9 const T = UI[LANG];
10 const at = (path) => new URL(path, ROOT).href;
11
12 const state = {
13 upstream: null,
14 distro: {},
15 distroLoaded: false,
16 status: null,
17 repo: 'https://github.com/debuman/php2shell-scanner',
18 last: null,
19 };
20 const FAMILY_NAMES = { debian: 'Debian', ubuntu: 'Ubuntu' };
21 // The status.json source that feeds each distribution family's data.
22 const FAMILY_SOURCE = { debian: 'osv-debian', ubuntu: 'osv-ubuntu', rhel: 'redhat' };
23 const RHEL_IDS = ['rhel', 'centos', 'rocky', 'almalinux', 'ol', 'cloudlinux', 'eurolinux', 'amzn'];
24 const STALE_AFTER_MS = 3 * 24 * 3600 * 1000;
25 const NBSP = String.fromCharCode(0xa0);
26
27 const labels = () => (engine.verdictLabels ? engine.verdictLabels(LANG) : engine.VERDICT_LABELS);
28
29 // The empty result pane as shipped in the page, restored by Clear.
30 const EMPTY_RESULT = [...(document.getElementById('result')?.childNodes || [])].map((n) => n.cloneNode(true));
31
32 // ── DOM helpers ─────────────────────────────────────────────────────
33
34 function h(tag, attrs, ...children) {
35 const el = document.createElement(tag);
36 for (const [k, v] of Object.entries(attrs || {})) {
37 if (v === null || v === undefined || v === false) continue;
38 if (k === 'class') el.className = v;
39 else el.setAttribute(k, v === true ? '' : String(v));
40 }
41 for (const c of children.flat()) {
42 if (c === null || c === undefined || c === false) continue;
43 el.append(c instanceof Node ? c : document.createTextNode(String(c)));
44 }
45 return el;
46 }
47
48 // Engine text is plain. Command-line flags and PHP setting names inside it are
49 // set as code so they read as literals and never break across lines.
50 const LITERAL = /(--[a-z][\w-]*|\b[a-z][a-z0-9]*(?:[._][a-z0-9]+)*_[a-z0-9]+(?:[._][a-z0-9]+)*(?:=-?\d+)?)/g;
51 function rich(text) {
52 return String(text).split(LITERAL).map((part, i) => (i % 2 ? h('code', { class: 'lit' }, part) : part));
53 }
54
55 // A package version with a line-break opportunity after "-" and "~" and before
56 // "+", so a long distribution revision can wrap between its parts and never
57 // inside a number. <wbr> is not copied with the text.
58 function breakable(text) {
59 const s = String(text);
60 const out = [];
61 let start = 0;
62 for (let i = 1; i < s.length; i++) {
63 if (s[i] === '+' || s[i - 1] === '-' || s[i - 1] === '~') {
64 out.push(s.slice(start, i), h('wbr'));
65 start = i;
66 }
67 }
68 out.push(s.slice(start));
69 return out;
70 }
71
72 const COPY_ICONS = document.querySelector('.copy')?.innerHTML || '';
73 function cmdline(code) {
74 const multi = code.includes('\n');
75 const button = h('button', { class: 'copy', type: 'button', 'aria-label': T.copyCommand });
76 button.innerHTML = COPY_ICONS; // static SVG markup taken from the page itself
77 return h('div', { class: 'cmdline' }, h(multi ? 'pre' : 'code', { class: multi ? 'cmd multi' : 'cmd' }, code), button);
78 }
79
80 async function writeClipboard(text) {
81 try {
82 await navigator.clipboard.writeText(text);
83 return true;
84 } catch {
85 return false;
86 }
87 }
88
89 async function copyCommand(button) {
90 const code = button.closest('.cmdline')?.querySelector('.cmd');
91 if (!code) return;
92 if (await writeClipboard(code.textContent)) {
93 const original = button.getAttribute('aria-label');
94 button.classList.add('is-done');
95 button.setAttribute('aria-label', T.copied);
96 setTimeout(() => {
97 button.classList.remove('is-done');
98 button.setAttribute('aria-label', original);
99 }, 1500);
100 } else {
101 const range = document.createRange();
102 range.selectNodeContents(code);
103 getSelection().removeAllRanges();
104 getSelection().addRange(range);
105 }
106 }
107
108 async function fetchOrNull(path, as) {
109 try {
110 const res = await fetch(at(path), { cache: 'no-cache' });
111 if (!res.ok) return null;
112 return as === 'text' ? await res.text() : await res.json();
113 } catch {
114 return null;
115 }
116 }
117
118 const dateTime = new Intl.DateTimeFormat(T.locale, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: 'UTC' });
119 const dateOnly = new Intl.DateTimeFormat(T.locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
120 const isDate = (iso) => typeof iso === 'string' && !Number.isNaN(Date.parse(iso));
121 const formatDateTime = (iso) => (isDate(iso) ? `${dateTime.format(new Date(iso))} ${T.utc}` : String(iso));
122 const formatDate = (iso) => (isDate(iso) ? dateOnly.format(new Date(iso)) : String(iso));
123 const listOf = (items) => (Intl.ListFormat ? new Intl.ListFormat(T.locale, { type: 'conjunction' }).format(items) : items.join(', '));
124
125 // ── Data freshness ──────────────────────────────────────────────────
126
127 // How current one source in status.json is. ok is true or false for the latest
128 // refresh, or null when status.json does not say. at is the last successful
129 // refresh when known: a source may report last_success, otherwise a source
130 // that succeeded counts as refreshed at checked_at.
131 function freshness(key) {
132 const s = state.status?.sources?.[key];
133 if (!s) return { ok: null, at: null };
134 const last = isDate(s.last_success) ? s.last_success : null;
135 if (s.ok) return { ok: true, at: last || (isDate(state.status.checked_at) ? state.status.checked_at : null) };
136 return { ok: false, at: last };
137 }
138
139 // The distribution family whose data the verdict rests on, or null when it was
140 // judged against upstream releases only. Prefers what the engine reports, then
141 // the Distribution choice, then the facts in the observation.
142 function resultFamily(r, obs, choice) {
143 // null from the engine means the verdict rests on upstream releases only.
144 const reported = r.distro?.family !== undefined ? r.distro.family : r.family;
145 if (typeof reported === 'string' || reported === null) return reported;
146 if (choice && choice !== 'auto') return choice === 'upstream' ? null : choice.split(':')[0];
147 const os = obs.os || {};
148 const like = `${os.id || ''} ${os.idLike || ''}`;
149 if (os.id === 'debian' || os.id === 'ubuntu') return os.id;
150 if (RHEL_IDS.includes(os.id) || (/\brhel\b/.test(like) && os.id !== 'fedora')) return 'rhel';
151 if (os.id) return null;
152 const versions = [obs.phpVersion, ...(obs.packages || []).map((p) => p.version)].filter(Boolean).join(' ');
153 if (/[~+]deb\d+u\d+/.test(versions)) return 'debian';
154 if (/ubuntu/.test(versions)) return 'ubuntu';
155 if (/\.el\d/.test(versions)) return 'rhel';
156 return null;
157 }
158
159 // What the page adds to the engine's result: the data line under the verdict,
160 // and reasons and notes about data that did not load or is not current.
161 function dataContext(r, obs, choice) {
162 const reasons = [...r.reasons];
163 const notes = [...r.notes];
164 const key = FAMILY_SOURCE[resultFamily(r, obs, choice)];
165 let line = null;
166 if (!key) {
167 const s = state.status;
168 const checked = s?.upstream_matches_advisory === true && isDate(s.checked_at);
169 line = `${T.upstreamData}: ${checked ? T.crossChecked(formatDateTime(s.checked_at)) : formatDate(state.upstream.updated)}`;
170 } else if (!state.distroLoaded) {
171 reasons.unshift(T.distroNotLoaded);
172 line = `${T.distroData}: ${T.notLoaded}`;
173 } else {
174 const f = freshness(key);
175 const when = f.at ? formatDateTime(f.at) : null;
176 if (f.ok === false) {
177 line = `${T.distroData}: ${when ? T.refreshFailedSince(when) : T.refreshFailed}`;
178 notes.push(T.sourceFailed(T.sourceNames[key] || key, when));
179 } else if (f.ok && when) {
180 line = `${T.distroData}: ${when}`;
181 if (Date.now() - Date.parse(f.at) > STALE_AFTER_MS) notes.push(T.stale(when));
182 } else if (isDate(state.distro.updated_at)) {
183 line = `${T.distroData}: ${T.dataChanged(formatDateTime(state.distro.updated_at))}`;
184 }
185 }
186 return { reasons, notes, line };
187 }
188
189 // ── Result ──────────────────────────────────────────────────────────
190
191 function block(title, ...body) {
192 return h('section', { class: 'block' }, h('h4', {}, title), ...body);
193 }
194
195 const exposureLabel = (r) => (r.verdict === 'patched' ? T.exposurePatched : T.exposure);
196
197 function renderResult(r, obs, choice) {
198 const out = document.getElementById('result');
199 const sources = [...new Set(obs.sources)].map((s) => T.sources[s] || s);
200 const { reasons, notes, line } = dataContext(r, obs, choice);
201 const meta = [sources.length ? `${T.input}: ${sources.join(' + ')}` : null, line].filter(Boolean).join(' · ');
202
203 const heading = h('h3', { tabindex: '-1' }, r.title);
204 const parts = [h('div', { class: 'verdict', 'data-verdict': r.verdict },
205 h('p', { class: 'verdict-label' }, labels()[r.verdict] || r.verdict),
206 heading,
207 reasons.length ? h('ul', { class: 'reasons' }, reasons.map((t) => h('li', {}, rich(t)))) : null,
208 meta ? h('p', { class: 'verdict-meta' }, meta) : null)];
209
210 const evidence = r.facts.map((f) => h('tr', {}, h('th', { scope: 'row' }, f.label), h('td', { class: 'mono' }, breakable(f.value))));
211 if (r.exposure) {
212 evidence.push(h('tr', {}, h('th', { scope: 'row' }, exposureLabel(r)),
213 h('td', {}, h('span', { class: `badge badge-${r.exposure.level}` }, T.levels[r.exposure.level] || r.exposure.level), rich(r.exposure.text))));
214 }
215 if (evidence.length) parts.push(block(T.evidence, h('table', { class: 'kv' }, h('tbody', {}, evidence))));
216
217 if (r.actions.length) {
218 parts.push(block(T.actions, h('ol', { class: 'actions' }, r.actions.map((a) => h('li', {}, rich(a.text), a.code ? cmdline(a.code) : null)))));
219 }
220
221 if (r.findings?.length) {
222 parts.push(block(T.indicators, h('ul', { class: 'indicators' }, r.findings.map((f) =>
223 h('li', {}, h('span', { class: `badge badge-${f.level}` }, T.levels[f.level] || f.level), h('span', {}, rich(f.text)))))));
224 }
225
226 if (r.mitigations.length) {
227 // On phones the value and status columns are hidden and shown under the
228 // setting name instead (.mit-inline); only one of the two is ever displayed.
229 const badge = (m) => h('span', { class: `badge badge-${m.status}` }, T.statuses[m.status] || m.status);
230 const rows = r.mitigations.flatMap((m) => [
231 h('tr', { class: 'mit-row' },
232 h('td', { class: 'setting' }, h('span', { class: 'mit-name' }, m.name),
233 h('span', { class: 'mit-inline' }, h('span', {}, m.value), badge(m))),
234 h('td', { class: 'value' }, m.value),
235 h('td', { class: 'status' }, badge(m))),
236 h('tr', { class: 'mit-note' }, h('td', { colspan: '3' }, rich(m.note))),
237 ]);
238 const patched = r.verdict === 'patched';
239 parts.push(block(patched ? T.hardening : T.mitigations,
240 h('p', {}, rich(patched ? T.hardeningIntro : T.mitIntro)),
241 h('table', { class: 'mit' },
242 h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, T.setting), h('th', { scope: 'col' }, T.value), h('th', { scope: 'col', class: 'status' }, T.status))),
243 h('tbody', {}, rows))));
244 }
245
246 if (r.advisories.length) {
247 parts.push(block(T.advisories, h('table', { class: 'kv' }, h('tbody', {},
248 r.advisories.map((a) => h('tr', {}, h('th', { scope: 'row' }, a.label), h('td', { class: 'mono' }, breakable(a.value))))))));
249 }
250
251 if (notes.length) parts.push(block(T.notes, h('ul', { class: 'plain' }, notes.map((t) => h('li', {}, rich(t))))));
252
253 out.replaceChildren(...parts);
254 state.last = { r, meta, reasons, notes };
255 document.getElementById('copy-report').hidden = false;
256 heading.focus({ preventScroll: true });
257 if (matchMedia('(max-width: 75rem)').matches) {
258 document.querySelector('.pane-result').scrollIntoView({ block: 'start' });
259 } else {
260 document.querySelector('.pane-result').scrollTop = 0;
261 }
262 }
263
264 function resetResult() {
265 document.getElementById('result').replaceChildren(...EMPTY_RESULT.map((n) => n.cloneNode(true)));
266 document.getElementById('copy-report').hidden = true;
267 state.last = null;
268 }
269
270 function reportMarkdown({ r, meta, reasons, notes }) {
271 const lines = [`## ${T.reportTitle(labels()[r.verdict])}`, '', `**${r.title}**`, ''];
272 for (const t of reasons) lines.push(`- ${t}`);
273 if (meta) lines.push('', `_${meta}_`);
274 if (r.facts.length) {
275 lines.push('', `### ${T.evidence}`);
276 for (const f of r.facts) lines.push(`- ${f.label}: \`${f.value}\``);
277 if (r.exposure) lines.push(`- ${exposureLabel(r)}: ${T.levels[r.exposure.level] || r.exposure.level}. ${r.exposure.text}`);
278 }
279 if (r.actions.length) {
280 lines.push('', `### ${T.actions}`);
281 r.actions.forEach((a, i) => {
282 lines.push(`${i + 1}. ${a.text}`);
283 if (a.code) lines.push('', ' ```sh', ...a.code.split('\n').map((c) => ` ${c}`), ' ```');
284 });
285 }
286 if (r.findings?.length) {
287 lines.push('', `### ${T.indicators}`);
288 for (const f of r.findings) lines.push(`- [${T.levels[f.level] || f.level}] ${f.text}`);
289 }
290 if (r.mitigations.length) {
291 lines.push('', `### ${r.verdict === 'patched' ? T.hardening : T.mitigations}`);
292 for (const m of r.mitigations) lines.push(`- \`${m.name}\` = \`${m.value}\`: ${T.statuses[m.status] || m.status}`);
293 }
294 if (r.advisories.length) {
295 lines.push('', `### ${T.advisories}`);
296 for (const a of r.advisories) lines.push(`- ${a.label}: ${a.value}`);
297 }
298 if (notes.length) {
299 lines.push('', `### ${T.notes}`);
300 for (const t of notes) lines.push(`- ${t}`);
301 }
302 lines.push('', T.reportFooter(state.repo, formatDateTime(new Date().toISOString())));
303 return lines.join('\n') + '\n';
304 }
305
306 async function loadDistro() {
307 const distro = await fetchOrNull('data/distro.json');
308 state.distro = distro || {};
309 state.distroLoaded = Boolean(distro);
310 }
311
312 async function runCheck(event) {
313 event?.preventDefault();
314 const input = document.getElementById('input');
315 if (!input.value.trim()) {
316 resetResult();
317 input.focus();
318 return;
319 }
320 // If the distribution data failed to load with the page, try once more
321 // rather than judge without it.
322 if (!state.distroLoaded) {
323 await loadDistro();
324 if (state.distroLoaded) renderReference();
325 }
326 const choice = document.getElementById('distro').value;
327 const obs = parseInput(input.value, { lang: LANG });
328 const result = engine.judge(obs, state.upstream, state.distro, { distro: choice, lang: LANG });
329 // Download commands in the steps follow the page's own address, like the ones in step 1.
330 if (engine.DEFAULT_DOWNLOADS) {
331 for (const a of result.actions) if (a.code) a.code = a.code.split(engine.DEFAULT_DOWNLOADS).join(at('downloads/'));
332 }
333 renderResult(result, obs, choice);
334 }
335
336 // ── Reference ───────────────────────────────────────────────────────
337
338 function renderUpstreamTable() {
339 const { fixed, eol, backport_branches: backports } = state.upstream;
340 const rows = Object.entries(fixed).map(([branch, version]) => h('tr', {},
341 h('th', { scope: 'row' }, `PHP${NBSP}${branch}`),
342 h('td', { class: 'mono' }, `< ${version}`),
343 h('td', { class: 'mono' }, version, eol?.[branch] ? h('span', { class: 'dim eol' }, T.eol(formatDate(eol[branch]))) : null)));
344 const branches = Object.keys(backports || {});
345 rows.push(h('tr', {}, h('th', { scope: 'row' }, T.branch81), h('td', {}, T.all54), h('td', {}, T.noUpstream(branches.length ? listOf(branches) : null))));
346 document.querySelector('#upstream-table tbody').replaceChildren(...rows);
347 }
348
349 // A fixed version from distro.json: a version string, null when the
350 // distribution tracks the CVE without a fix yet, or missing when there is no
351 // record for it, which the engine treats as no data rather than as pending.
352 function fixedValue(value) {
353 if (typeof value === 'string') return breakable(value);
354 return value === null
355 ? h('span', { class: 'badge badge-pending' }, T.fixPending)
356 : h('span', { class: 'badge badge-unknown' }, T.noData);
357 }
358
359 function fixedCell(entry, cves) {
360 const values = cves.map((c) => entry.fixed?.[c]);
361 if (values.every((v) => v === values[0])) {
362 return h('td', { class: typeof values[0] === 'string' ? 'mono ver' : null }, fixedValue(values[0]));
363 }
364 return h('td', { class: 'mono ver' }, cves.map((c, i) =>
365 h('span', { class: 'ver-line' }, `${c.replace(/^CVE-\d{4}-/, '')}:${NBSP}`, fixedValue(values[i]))));
366 }
367
368 const sortedReleases = (family) => Object.entries(state.distro[family] || {}).sort(([a], [b]) => parseFloat(b) - parseFloat(a));
369
370 function renderDistroTables() {
371 const cves = state.upstream.cves.map((c) => c.id);
372 const rows = [];
373 for (const family of ['debian', 'ubuntu']) {
374 for (const [release, rel] of sortedReleases(family)) {
375 for (const [pkg, entry] of Object.entries(rel.packages || {})) {
376 rows.push(h('tr', {},
377 h('th', { scope: 'row' }, `${FAMILY_NAMES[family]} ${release}`, rel.codename ? h('span', { class: 'dim' }, ` ${rel.codename}`) : null,
378 entry.pro ? h('span', { class: 'badge badge-info', title: T.proTitle }, 'Pro') : null),
379 h('td', { class: 'mono' }, pkg),
380 fixedCell(entry, cves)));
381 }
382 }
383 }
384 if (!rows.length) rows.push(h('tr', {}, h('td', { colspan: '3' }, T.distroFailed)));
385 document.querySelector('#distro-table tbody').replaceChildren(...rows);
386
387 const rh = Object.entries(state.distro.redhat || {}).sort(([a], [b]) => Number(b) - Number(a));
388 const rhRows = [];
389 for (const [rel, v] of rh) {
390 const seen = new Set();
391 const items = [];
392 for (const a of v.advisories || []) {
393 const key = `${a.advisory} ${a.package}`;
394 if (!seen.has(key)) { seen.add(key); items.push(`${a.advisory} ${a.package}`); }
395 }
396 for (const s of v.states || []) {
397 const key = `${s.package} ${s.state}`;
398 if (!seen.has(key)) { seen.add(key); items.push(`${s.package}: ${s.state}`); }
399 }
400 items.forEach((item, i) => rhRows.push(h('tr', {}, i === 0 ? h('th', { scope: 'row', rowspan: String(items.length) }, `RHEL ${rel}`) : null, h('td', {}, item))));
401 }
402 document.getElementById('redhat-list').replaceChildren(...(rhRows.length
403 ? [h('p', { class: 'rh-intro' }, T.rhIntro), h('table', { class: 'rh' }, h('tbody', {}, rhRows))]
404 : [h('p', { class: 'rh-intro' }, T.rhFailed)]));
405 }
406
407 function renderDataStatus() {
408 const s = state.status;
409 const edited = formatDate(state.upstream.updated);
410 const parts = [];
411 if (!s) parts.push(T.statusUpstream(edited));
412 else if (s.upstream_matches_advisory === true && isDate(s.checked_at)) parts.push(T.statusUpstreamChecked(formatDateTime(s.checked_at)));
413 else if (s.upstream_matches_advisory === false) parts.push(T.statusUpstreamMismatch(edited));
414 else parts.push(T.statusUpstreamUnchecked(edited));
415
416 if (!state.distroLoaded) parts.push(T.statusDistroMissing);
417 else if (isDate(state.distro.updated_at)) {
418 parts.push(T.statusDistro(formatDateTime(state.distro.updated_at), isDate(s?.checked_at) ? formatDateTime(s.checked_at) : null));
419 }
420 // The php-src advisories only cross-check upstream.json; the line above covers them.
421 const failed = Object.entries(s?.sources || {}).filter(([k, v]) => k !== 'ghsa' && v && !v.ok);
422 const name = (k) => T.sourceNames[k] || k;
423 if (failed.length) parts.push(T.statusFailed(listOf(failed.map(([k]) => name(k)))));
424 for (const [k, v] of failed) {
425 if (isDate(v.last_success)) parts.push(T.statusLastSuccess(name(k), formatDateTime(v.last_success)));
426 }
427 document.getElementById('data-status').textContent = parts.join(' ');
428 }
429
430 function fillDistroSelect() {
431 const select = document.getElementById('distro');
432 const chosen = select.value;
433 select.replaceChildren(select.options[0]);
434 for (const family of ['debian', 'ubuntu']) {
435 const releases = sortedReleases(family);
436 if (!releases.length) continue;
437 select.append(h('optgroup', { label: FAMILY_NAMES[family] },
438 releases.map(([release, rel]) => h('option', { value: `${family}:${release}` }, `${FAMILY_NAMES[family]} ${release}${rel.codename ? ` (${rel.codename})` : ''}`))));
439 }
440 const rh = Object.keys(state.distro.redhat || {}).sort((a, b) => Number(b) - Number(a));
441 if (rh.length) select.append(h('optgroup', { label: T.rhelFamily }, rh.map((rel) => h('option', { value: `rhel:${rel}` }, T.rhelOption(rel)))));
442 select.append(h('option', { value: 'upstream' }, T.upstreamOption));
443 select.value = chosen;
444 if (select.value !== chosen) select.value = 'auto';
445 }
446
447 function renderReference() {
448 renderUpstreamTable();
449 renderDistroTables();
450 renderDataStatus();
451 fillDistroSelect();
452 }
453
454 // ── Page wiring ─────────────────────────────────────────────────────
455
456 function setupTabs() {
457 const tabs = [...document.querySelectorAll('.tab')];
458 const select = (tab, focus) => {
459 for (const t of tabs) {
460 const on = t === tab;
461 t.setAttribute('aria-selected', String(on));
462 t.tabIndex = on ? 0 : -1;
463 document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
464 }
465 if (focus) tab.focus();
466 };
467 tabs.forEach((tab, i) => {
468 tab.addEventListener('click', () => select(tab));
469 tab.addEventListener('keydown', (e) => {
470 const next = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
471 if (next === undefined) return;
472 e.preventDefault();
473 select(tabs[(next + tabs.length) % tabs.length], true);
474 });
475 });
476 }
477
478 function setupDownloads(sums) {
479 const base = at('downloads/');
480 for (const code of document.querySelectorAll('.cmd[data-download]')) {
481 const then = code.dataset.then;
482 code.textContent = `curl -fsSLO ${base}${code.dataset.download}${then ? ` && ${then}` : ''}`;
483 }
484 for (const line of (sums || '').split('\n')) {
485 const m = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(line.trim());
486 const el = m && document.querySelector(`[data-sha-for="${m[2]}"]`);
487 if (el) el.textContent = `sha256 ${m[1]}`;
488 }
489 }
490
491 function setupRepoLinks(config) {
492 if (!config?.repository) return;
493 state.repo = config.repository;
494 for (const a of document.querySelectorAll('#repo-link, .repo-link')) a.href = config.repository;
495 }
496
497 // ── Examples ────────────────────────────────────────────────────────
498
499 function probeOutput(report) {
500 const full = {
501 tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, generated_at: '2026-10-23T01:00:00Z',
502 php_os: 'Linux', container: false, extensions: { session: true, opcache: true }, self_deleted: true, ...report,
503 };
504 return `php2shell-selfcheck 1.0.0\n\n-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify(full, null, 2)}\n-----END PHP2SHELL SELFCHECK-----\n`;
505 }
506
507 const UBUNTU_2204 = { PRETTY_NAME: 'Ubuntu 22.04.5 LTS', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy', ID: 'ubuntu', ID_LIKE: 'debian' };
508 const DEFAULT_INI = {
509 memory_limit: '128M', post_max_size: '8M', max_input_nesting_level: '64',
510 'session.upload_progress.enabled': '1', 'session.auto_start': '0', expose_php: '1',
511 };
512 const UBUNTU_PACKAGES = [
513 { name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' },
514 { name: 'php8.1-common', version: '8.1.2-1ubuntu2.28', source: 'php8.1' },
515 ];
516
517 const EXAMPLES = {
518 backport: probeOutput({
519 php_version: '8.1.2-1ubuntu2.28', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2',
520 server_software: 'Apache/2.4.52 (Ubuntu)', os_release: UBUNTU_2204, dpkg_packages: UBUNTU_PACKAGES,
521 ini: { ...DEFAULT_INI, expose_php: '' },
522 }),
523 restart: probeOutput({
524 php_version: '8.1.2-1ubuntu2.20', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2',
525 server_software: 'Apache/2.4.52 (Ubuntu)', os_release: UBUNTU_2204, dpkg_packages: UBUNTU_PACKAGES, ini: DEFAULT_INI,
526 }),
527 docker: probeOutput({
528 php_version: '8.3.12', sapi: 'apache2handler', config_path: '/usr/local/etc/php',
529 server_software: 'Apache/2.4.62 (Debian)', container: true,
530 os_release: { PRETTY_NAME: 'Debian GNU/Linux 12 (bookworm)', VERSION_ID: '12', VERSION_CODENAME: 'bookworm', ID: 'debian' },
531 dpkg_packages: [], ini: DEFAULT_INI,
532 }),
533 rhel: `php2shell-server-check 1.0.0
534 -----BEGIN PHP2SHELL SERVERCHECK-----
535 tool|php2shell-server-check|1.0.0
536 os|ID|almalinux
537 os|ID_LIKE|rhel centos fedora
538 os|VERSION_ID|9.8
539 os|PRETTY_NAME|AlmaLinux 9.8 (Olive Jaguar)
540 pkg|rpm|php-common|8.0.30-8.el9_8.1|
541 pkg|rpm|php-fpm|8.0.30-8.el9_8.1|
542 changelog|php-common|CVE-2026-109618|1
543 changelog|php-common|CVE-2026-109619|1
544 proc|php-fpm|1|ok
545 webuser_proc|0
546 log|oom|0
547 log|crash|0
548 log|bigsession|0
549 -----END PHP2SHELL SERVERCHECK-----
550 `,
551 };
552 const EXAMPLE_TEXTS = new Set(Object.values(EXAMPLES));
553
554 // Choosing an example only selects it; Load replaces the input and runs the
555 // check. Pasted output is never replaced without asking, because the probe
556 // has already deleted itself and cannot simply be run again.
557 function loadExample() {
558 const example = document.getElementById('example');
559 const input = document.getElementById('input');
560 const text = EXAMPLES[example.value];
561 if (!text) {
562 example.focus();
563 return;
564 }
565 if (input.value.trim() && !EXAMPLE_TEXTS.has(input.value) && !confirm(T.replaceConfirm)) return;
566 input.value = text;
567 example.value = '';
568 document.getElementById('distro').value = 'auto';
569 runCheck();
570 }
571
572 // ── Start ───────────────────────────────────────────────────────────
573
574 async function init() {
575 setupTabs();
576 document.addEventListener('click', (e) => {
577 const btn = e.target.closest('.copy');
578 if (btn) copyCommand(btn);
579 });
580
581 const [upstream, distro, status, config, sums] = await Promise.all([
582 fetchOrNull('data/upstream.json'),
583 fetchOrNull('data/distro.json'),
584 fetchOrNull('data/status.json'),
585 fetchOrNull('config.json'),
586 fetchOrNull('downloads/SHA256SUMS', 'text'),
587 ]);
588 setupDownloads(sums);
589 setupRepoLinks(config);
590
591 if (!upstream) {
592 document.getElementById('result').replaceChildren(h('div', { class: 'verdict', 'data-verdict': 'unknown' },
593 h('h3', {}, T.loadFailedTitle), h('ul', { class: 'reasons' }, h('li', {}, T.loadFailedBody))));
594 return;
595 }
596 state.upstream = upstream;
597 state.distro = distro || {};
598 state.distroLoaded = Boolean(distro);
599 state.status = status;
600 renderReference();
601
602 const input = document.getElementById('input');
603 document.getElementById('check-form').addEventListener('submit', runCheck);
604 input.addEventListener('keydown', (e) => {
605 if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) runCheck(e);
606 });
607 document.getElementById('clear').addEventListener('click', () => {
608 input.value = '';
609 document.getElementById('example').value = '';
610 resetResult();
611 input.focus();
612 });
613 document.getElementById('load-example').addEventListener('click', loadExample);
614 const copyReport = document.getElementById('copy-report');
615 copyReport.addEventListener('click', async () => {
616 if (!state.last) return;
617 const ok = await writeClipboard(reportMarkdown(state.last));
618 copyReport.textContent = ok ? T.copied : T.copyFailed;
619 setTimeout(() => { copyReport.textContent = T.copyReport; }, 1500);
620 });
621 }
622
623 init();