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

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