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/lib/judge.js (141702B)

      1 // Computes the verdict from an observation (parse.js) and the fixed-version data.
      2 // Rule: when the evidence is not enough, never call it "safe". Leave the verdict as "unknown".
      3 // All user-facing text comes from messages.js, in the language given by options.lang.
      4 //
      5 // Guards that keep a vulnerable PHP from being reported as patched:
      6 // - A distribution package vouches for the running PHP only when its upstream version matches.
      7 // - A release inferred from the version string is accepted only when it ships that upstream version.
      8 // - A backport under the same upstream version counts only for the distribution's own revisions (ubuntu,
      9 //   ~debNNuN): a PPA's or another vendor's build that merely sorts above the fixed version does not.
     10 // - Changelog evidence counts only for the package of the PHP being judged, at the version that was read.
     11 // - A PHP whose configuration lives outside /etc is not a distribution package.
     12 // - A container probe is never judged by host packages, and outputs of different systems or moments are not
     13 //   mixed (parse.js keeps the server script's system data out then).
     14 // - -dev, alpha, beta, RC and pre builds are pre-releases.
     15 // - Every PHP in the input counts: several outputs, and the other installed builds next to the probe's PHP, give
     16 //   the worst verdict. A running PHP process that no checked build accounts for rules out Patched.
     17 // - Patched on the installed package alone needs the running processes to have been checked: as root, readable,
     18 //   and not cut off.
     19 // - A Distribution list choice that contradicts the OS release or the version string is not used.
     20 
     21 import { parsePhpVersion, comparePhp, compareDebian, compareRpm, splitDebian, debUpstream, debComparable, isSuryBuild, suryBuildDate } from './version.js';
     22 import { translator, formatDate } from './messages.js';
     23 
     24 // table[key] for a key that comes from the input (a SAPI name, an OS id): only the table's own entries, never a
     25 // member of Object.prototype such as "constructor" or "toString".
     26 function own(table, key) {
     27   return table && typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
     28 }
     29 
     30 const RHEL_IDS = ['rhel', 'centos', 'rocky', 'almalinux', 'ol', 'cloudlinux', 'eurolinux', 'amzn'];
     31 const VERDICTS = ['patched', 'vulnerable', 'pending', 'restart', 'unknown'];
     32 // When several PHP builds are judged, the worst one is reported.
     33 const SEVERITY = { patched: 0, unknown: 1, restart: 2, pending: 3, vulnerable: 4 };
     34 
     35 // Red Hat fix_state values that have a message; any other value is shown as it is.
     36 const RHEL_STATES = {
     37   Affected: 'rhelState.affected',
     38   'Under investigation': 'rhelState.underInvestigation',
     39   'Fix deferred': 'rhelState.fixDeferred',
     40   'Will not fix': 'rhelState.willNotFix',
     41   'Not affected': 'rhelState.notAffected',
     42   'Out of support scope': 'rhelState.outOfSupportScope',
     43 };
     44 
     45 // The branch of Red Hat's own php package in releases whose advisories do not name it. A Red Hat state for the
     46 // plain "php" package describes that branch only.
     47 const RHEL_BASE_BRANCH = { 6: '5.3', 7: '5.4' };
     48 
     49 // Which source of compromise indicators each server script count comes from.
     50 const FINDING_SOURCE = { oom: 'logs', crash: 'logs', bigsession: 'sessions', webuser_proc: 'processes', recent_php: 'webroot' };
     51 
     52 export function verdictTitles(lang = 'en') {
     53   const t = translator(lang);
     54   return Object.fromEntries(VERDICTS.map((v) => [v, t(`verdict.title.${v}`)]));
     55 }
     56 
     57 export function verdictLabels(lang = 'en') {
     58   const t = translator(lang);
     59   return Object.fromEntries(VERDICTS.map((v) => [v, t(`verdict.label.${v}`)]));
     60 }
     61 
     62 export const VERDICT_TITLES = verdictTitles('en');
     63 
     64 export const VERDICT_LABELS = verdictLabels('en');
     65 
     66 export function judge(obs, upstream, distro, options = {}) {
     67   try {
     68     const r = Array.isArray(obs.parts) && obs.parts.length > 1 ? judgeParts(obs, upstream, distro, options) : judgeOne(obs, upstream, distro, options);
     69     // Part of the input was not read (it was too long, or held too many outputs): what was left out may be worse.
     70     if (obs.inputCut && r.verdict === 'patched') {
     71       const t = translator(options.lang || 'en');
     72       r.verdict = 'unknown';
     73       r.title = t('verdict.title.unknown');
     74       r.reasons.push(t('reason.inputCut'));
     75       r.actions = [{ text: t('step.collect.shorter') }];
     76     }
     77     return r;
     78   } catch {
     79     // Never leave the page without a result. Nothing is concluded from an input the engine could not handle.
     80     const t = translator(options.lang || 'en');
     81     return {
     82       verdict: 'unknown',
     83       title: t('verdict.title.unknown'),
     84       reasons: [t('reason.notRead')],
     85       actions: [],
     86       facts: [],
     87       mitigations: [],
     88       notes: [],
     89       advisories: [],
     90       findings: [],
     91       family: null,
     92     };
     93   }
     94 }
     95 
     96 // Several separate outputs in one paste: each is judged on its own, and the worst result is reported.
     97 function judgeParts(obs, upstream, distro, options) {
     98   const lang = options.lang || 'en';
     99   const t = translator(lang);
    100   const labels = verdictLabels(lang);
    101   const results = obs.parts.map((part) => ({ part, r: judgeOne(part, upstream, distro, options) }));
    102   const worst = results.reduce((w, x) => (SEVERITY[x.r.verdict] > SEVERITY[w.r.verdict] ? x : w));
    103   const notes = [t('note.severalOutputs', { n: results.length })];
    104   // Notes about the input as a whole (part of it was cut, too many outputs) come with the combined observation.
    105   for (const n of obs.notes || []) if (!notes.includes(n)) notes.push(n);
    106   const advisories = [];
    107   const findings = [];
    108   for (const { r } of results) {
    109     for (const n of r.notes) if (!notes.includes(n)) notes.push(n);
    110     for (const x of r.advisories) if (!advisories.some((y) => y.label === x.label && y.value === x.value)) advisories.push(x);
    111     for (const f of r.findings) if (!findings.some((g) => g.level === f.level && g.text === f.text)) findings.push(f);
    112   }
    113   // "Nothing suspicious" from one output must not stand next to a finding, or a source another output could not read.
    114   const clear = findings.every((f) => f.level === 'ok');
    115   const r = {
    116     verdict: worst.r.verdict,
    117     title: worst.r.title,
    118     reasons: results.map(({ part, r: x }) => t('reason.forPart', { part: partLabel(t, part), verdict: labels[x.verdict], reasons: x.reasons.join(' ') })),
    119     actions: worst.r.actions,
    120     facts: worst.r.facts,
    121     mitigations: worst.r.mitigations,
    122     notes,
    123     advisories,
    124     findings: clear ? findings : findings.filter((f) => f.level !== 'ok'),
    125     family: worst.r.family,
    126   };
    127   if (worst.r.exposure) r.exposure = worst.r.exposure;
    128   if (worst.r.eol) r.eol = true;
    129   return r;
    130 }
    131 
    132 function partLabel(t, part) {
    133   const version = part.phpVersion;
    134   if (part.sources.includes('selfcheck')) return version ? t('part.probe', { version }) : t('part.probeNoVersion');
    135   if (part.sources.includes('header')) return t('part.header', { version });
    136   if (part.sources.includes('cli')) return t('part.cli', { version, sapi: part.sapi });
    137   if (part.sources.includes('servercheck')) {
    138     const os = part.os || {};
    139     return t('part.server', { system: os.pretty || [os.id, os.versionId].filter(Boolean).join(' ') || t('distro.unknown') });
    140   }
    141   return version ? t('part.version', { version }) : t('part.probeNoVersion');
    142 }
    143 
    144 function judgeOne(obs, upstream, distro, options) {
    145   const lang = options.lang || 'en';
    146   const t = translator(lang);
    147   const r = {
    148     verdict: 'unknown',
    149     reasons: [],
    150     actions: [],
    151     facts: [],
    152     mitigations: [],
    153     notes: [...(obs.notes || [])],
    154     advisories: [],
    155   };
    156   // With only the server script output, infer the SAPI from running processes (the script reports Apache only when it has libphp loaded).
    157   const inferredSapi = obs.sapi ? null : inferSapi(obs.processes);
    158   const ctx = {
    159     t,
    160     lang,
    161     upstream,
    162     distro: distro || {},
    163     cves: upstream.cves.map((c) => c.id),
    164     sapi: obs.sapi || inferredSapi,
    165   };
    166 
    167   // The running PHP comes from the probe, a header, -v output or a version string. With server script output,
    168   // every installed PHP build is judged as well (one per Debian source package, rpm collection or apk prefix),
    169   // and the worst verdict is reported.
    170   const running = parsePhpVersion(obs.phpVersion);
    171   const installed = running && !obs.serverData ? [] : installedBuilds(obs);
    172   if (!running && !installed.length) {
    173     r.reasons.push(t('reason.noVersion'));
    174     r.family = null;
    175     return finish(ctx, r, obs, {}, null, { noVersion: true });
    176   }
    177   const results = [];
    178   if (running) {
    179     const d = detectDistro(obs, running, ctx.distro, options.distro);
    180     results.push({ ver: running, build: null, d, a: assess(ctx, obs, running, null, d) });
    181   }
    182   // The installed build the running PHP was judged with is not judged a second time.
    183   const used = running ? results[0].a.usedKeys : [];
    184   for (const b of installed) {
    185     if (running && (used.includes(b.key) || (!used.length && b.ver.upstream === running.upstream))) continue;
    186     const d = detectDistro(obs, b.ver, ctx.distro, options.distro, b);
    187     results.push({ ver: b.ver, build: b, d, a: assess(ctx, obs, b.ver, b, d) });
    188   }
    189 
    190   // The PHP processes each of them may run. A process none of them can run is a PHP that was not checked.
    191   const procs = obs.processes || [];
    192   for (const x of results) {
    193     x.mine = procs.filter((p) => mayRun(p, x, installed));
    194     if (x.a.path !== 'prerelease') checkRunning(ctx, x.a, obs, x.ver, x.build, x.mine);
    195   }
    196   const orphans = procs.filter((p) => !results.some((x) => x.mine.includes(p)));
    197   const main = results.reduce((worst, x) => (SEVERITY[x.a.verdict] > SEVERITY[worst.a.verdict] ? x : worst));
    198 
    199   if (running) r.facts.push({ label: t('fact.phpVersion'), value: running.raw });
    200   const others = results.filter((x) => x.build);
    201   if (others.length) {
    202     r.facts.push({ label: t(running ? 'fact.otherBuilds' : 'fact.installedVersion'), value: others.map((x) => pkgLabel(x.build.main)).join(', ') });
    203   }
    204   if (ctx.sapi) {
    205     const label = sapiLabel(ctx.sapi, lang);
    206     r.facts.push({ label: t('fact.sapi'), value: inferredSapi ? t('fact.sapiInferred', { sapi: label }) : label });
    207   }
    208   r.facts.push({ label: t('fact.distribution'), value: distroLabel(t, main.d, obs) });
    209   if (obs.serverSoftware) r.facts.push({ label: t('fact.webServer'), value: obs.serverSoftware });
    210 
    211   r.verdict = main.a.verdict;
    212   r.family = dataFamily(main.d, main.a.path);
    213   if (main.a.eol) r.eol = true;
    214   const labels = verdictLabels(lang);
    215   for (const x of results) {
    216     const reasons = x.a.reasons.join(' ');
    217     if (results.length === 1) r.reasons.push(...x.a.reasons);
    218     else if (x.build) r.reasons.push(t('reason.forBuild', { build: pkgLabel(x.build.main), verdict: labels[x.a.verdict], reasons }));
    219     else r.reasons.push(t('reason.forRunning', { version: x.ver.raw, verdict: labels[x.a.verdict], reasons }));
    220     for (const n of x.a.notes) if (!r.notes.includes(n)) r.notes.push(n);
    221     for (const y of x.a.advisories) if (!r.advisories.some((z) => z.label === y.label && z.value === y.value)) r.advisories.push(y);
    222     r.facts.push(...x.a.facts);
    223   }
    224 
    225   // What the remediation steps work from: the worst result's assessment, the processes of it that still use files
    226   // from before an update, and all of its processes (their names give the services to restart and configure).
    227   const info = {
    228     path: main.a.path,
    229     build: main.build,
    230     a: main.a,
    231     stale: [...new Set(main.mine.filter((p) => p.status === 'stale').map((p) => p.comm))],
    232     running: [...new Set(main.mine.map((p) => p.comm))],
    233   };
    234   // Stale processes of another PHP than the probe's: what started them is not known, so their names are given.
    235   if (main.a.staleOther && info.stale.length) info.genericRestart = info.stale.join(', ');
    236   const stale = orphans.filter((p) => p.status === 'stale');
    237   const unaccounted = orphans.filter((p) => p.status !== 'stale');
    238   if (stale.length) {
    239     r.reasons.push(t('reason.staleUnaccounted', { names: commNames(stale) }));
    240     if (r.verdict === 'patched') {
    241       r.verdict = 'restart';
    242       info.genericRestart = commNames(stale);
    243     }
    244   }
    245   // A Debian or Ubuntu PHP-FPM or CGI process of a branch whose other packages are installed but whose own package is
    246   // not listed: an interrupted update may have left that package half-installed (dpkg lists only installed ones).
    247   const halfInstalled = unaccounted.map((p) => {
    248     const m = /^php-(fpm|cgi)(\d+\.\d+)$/.exec(p.comm);
    249     const b = m && installed.find((x) => x.packages[0]?.manager === 'dpkg' && x.ver.branch === m[2]);
    250     return b ? { comm: p.comm, pkg: `php${m[2]}-${m[1]}`, src: debSource(b.main) } : null;
    251   }).filter(Boolean);
    252   if (unaccounted.length) {
    253     r.reasons.push(t('reason.unaccountedProcesses', { names: commNames(unaccounted) }));
    254     if (r.verdict === 'patched' && halfInstalled.length) {
    255       const h = halfInstalled[0];
    256       r.verdict = 'unknown';
    257       r.reasons.push(t('reason.sapiPackageMissingProc', { comm: h.comm, pkg: h.pkg, src: h.src }));
    258       info.need = 'sapiPackage';
    259       info.sapiPackage = h.pkg;
    260     } else if (r.verdict === 'patched') {
    261       r.verdict = 'unknown';
    262       info.path = 'undetermined';
    263       info.need = 'elsewhere';
    264       info.genericRestart = commNames(unaccounted);
    265     }
    266   }
    267   // PHP processes the server script could not check (unreadable, or a status line cut off) may be any PHP, also one
    268   // that was not restarted after an update. A PHP_VERSION that shows the fix vouches only for the probe's own PHP.
    269   if (obs.serverData && obs.procUnreadable > 0 && r.verdict === 'patched') {
    270     r.verdict = 'unknown';
    271     r.reasons.push(t(obs.root === true ? 'reason.processesUnreadableRoot' : 'reason.processesUnreadable', { n: obs.procUnreadable }));
    272     info.path = 'undetermined';
    273     info.need = 'unreadable';
    274   }
    275   // The probe and the server script describe different moments (see parse.js): which one is current is unknown, so
    276   // neither the probe's PHP_VERSION nor the installed packages settle it.
    277   if (obs.serverConflict && r.verdict === 'patched') {
    278     r.verdict = 'unknown';
    279     if (!r.reasons.includes(t('reason.serverConflict'))) r.reasons.push(t('reason.serverConflict'));
    280     info.path = 'undetermined';
    281     info.need = 'together';
    282   }
    283   if ((obs.truncated || obs.headCut) && r.verdict === 'patched') {
    284     r.verdict = 'unknown';
    285     r.reasons.push(t(obs.truncated ? 'reason.serverTruncated' : 'reason.serverHeadCut'));
    286     info.path = 'undetermined';
    287     info.need = 'whole';
    288   }
    289   // A probe that could not tell whether it runs in a container, next to server script output nothing ties to it: the
    290   // next step is the one that ties them, not the same server script output again.
    291   if (obs.unlinkedHidden && r.verdict === 'unknown') {
    292     info.path = 'undetermined';
    293     info.need = 'hiddenLink';
    294   }
    295   return finish(ctx, r, obs, main.d, main.ver, info);
    296 }
    297 
    298 // Judges one PHP build. build is null for the running PHP, or an installed build (server script output).
    299 function assess(ctx, obs, ver, build, d) {
    300   const { t, upstream } = ctx;
    301   const a = {
    302     verdict: 'unknown',
    303     reasons: [],
    304     notes: [],
    305     advisories: [],
    306     facts: [],
    307     eol: false,
    308     // How the verdict was reached; it picks the update advice.
    309     path: 'upstream',
    310     // The running version string itself shows the fix (upstream release, or a fixed distribution revision).
    311     proven: false,
    312     // A Patched verdict rests on the installed package, not on what is loaded in memory.
    313     restsOnPackage: false,
    314     // Keys of the installed builds (see installedBuilds) this verdict was reached with.
    315     usedKeys: [],
    316     // When the verdict stays open: what to collect next (see collectSteps).
    317     need: null,
    318   };
    319   if (d.overridden) a.notes.push(overrideNote(t, d, obs, ver));
    320   const branches = Object.keys(upstream.fixed).sort(compareBranch);
    321   const fixedUp = upstream.fixed[ver.branch];
    322 
    323   if (ver.pre) {
    324     a.path = 'prerelease';
    325     a.reasons.push(t('reason.preRelease', { version: ver.raw }));
    326     return a;
    327   }
    328   if (!fixedUp && compareBranch(ver.branch, branches[branches.length - 1]) > 0) {
    329     a.path = 'prerelease';
    330     a.reasons.push(t('reason.newerBranch', { branch: ver.branch, branches: branches.join(', ') }));
    331     return a;
    332   }
    333   if (fixedUp && comparePhp(ver, fixedUp) >= 0) {
    334     a.verdict = 'patched';
    335     a.reasons.push(t('reason.upstreamPatched', { version: ver.upstream, fixed: fixedUp }));
    336     a.proven = !build;
    337     a.restsOnPackage = Boolean(build);
    338     if (!build) checkDowngrade(ctx, a, obs, ver, d);
    339   } else {
    340     judgeBuild(ctx, a, obs, ver, build, d);
    341   }
    342   return a;
    343 }
    344 
    345 // The running PHP has the fix, but the package that provides it on disk is older: it was downgraded (or replaced by
    346 // another repository's build) after the service started, and the next restart loads that package's build. On
    347 // Debian and Ubuntu the package of a SAPI (php8.2-fpm, libapache2-mod-php8.2) carries the whole PHP engine, and
    348 // the configuration directory of the branch (/etc/php/8.2/fpm) ties the running PHP to it.
    349 function checkDowngrade(ctx, a, obs, ver, d) {
    350   const { t } = ctx;
    351   if (d.family !== 'debian' && d.family !== 'ubuntu') return;
    352   if (obs.configPath && !debLayoutPath(obs.configPath, ver)) return;
    353   const pkg = sapiPackage(obs.packages, ver, ctx.sapi);
    354   if (!pkg || cmpUp(debUpstream(pkg.version), ver.upstream) >= 0) return;
    355   a.usedKeys.push(buildKey(pkg));
    356   a.path = isSuryBuild(pkg.version) ? 'sury' : 'deb';
    357   a.facts.push({ label: t('fact.packageChecked'), value: pkgLabel(pkg) });
    358   if (debPackageFixed(ctx, d, pkg, ver)) {
    359     a.notes.push(t('note.olderPackageFixed', { pkg: pkgLabel(pkg), version: ver.raw }));
    360     return;
    361   }
    362   a.verdict = 'vulnerable';
    363   a.downgraded = true;
    364   a.reasons = [t('reason.installedOlderUnfixed', { installed: pkgLabel(pkg), running: ver.raw })];
    365 }
    366 
    367 // Whether a Debian or Ubuntu package version has both fixes: by the release's fixed version when the data has one
    368 // (and the revision is the distribution's own), otherwise by its upstream version.
    369 function debPackageFixed(ctx, d, pkg, ver) {
    370   const { cves, upstream } = ctx;
    371   const fixedUp = upstream.fixed[ver.branch];
    372   if (fixedUp && cmpUp(debUpstream(pkg.version), fixedUp) >= 0) return true;
    373   if (isSuryBuild(pkg.version)) return false;
    374   const entry = own(own(ctx.distro[d.family], d.release)?.packages, debSource(pkg));
    375   const fixed = cves.map((c) => entry?.fixed?.[c]);
    376   if (!fixed.every(Boolean)) return false;
    377   // Below the upstream fix, only the release's own backport has the fixes: the same upstream version as its fixed
    378   // package, at or above its fixed revision, and a revision the distribution builds itself.
    379   if (cmpUp(debUpstream(pkg.version), debUpstream(fixed[0])) !== 0) return false;
    380   return fixed.every((f) => debAtLeast(pkg.version, f)) && vendorBuild(d.family, pkg.version);
    381 }
    382 
    383 // The configuration directory of a Debian or Ubuntu PHP package of this branch: /etc/php/8.2/..., /etc/php5/...
    384 function debLayoutPath(path, ver) {
    385   const base = ver.major >= 7 ? `/etc/php/${ver.branch}` : '/etc/php5';
    386   return path === base || path.startsWith(`${base}/`);
    387 }
    388 
    389 // The Debian or Ubuntu package that provides this SAPI of this branch: php8.2-fpm, libapache2-mod-php8.2, php8.2-cgi.
    390 function sapiPackageName(ver, sapi) {
    391   const src = srcFor(ver);
    392   return own({ apache2handler: `libapache2-mod-${src}`, 'fpm-fcgi': `${src}-fpm`, 'cgi-fcgi': `${src}-cgi`, cgi: `${src}-cgi`, cli: `${src}-cli` }, sapi) || null;
    393 }
    394 
    395 function sapiPackage(packages, ver, sapi) {
    396   const name = sapiPackageName(ver, sapi);
    397   return name ? packages.find((p) => p.manager === 'dpkg' && p.name === name) || null : null;
    398 }
    399 
    400 function judgeBuild(ctx, a, obs, ver, build, d) {
    401   const { t } = ctx;
    402   const debLike = d.family === 'debian' || d.family === 'ubuntu';
    403   const pkg = debLike ? pickDebPackage(obs, ver, ctx.sapi, build) : null;
    404   if (isSuryBuild(ver.raw) || (pkg && isSuryBuild(pkg.version))) {
    405     judgeSury(ctx, a, obs, ver, build, pkg && isSuryBuild(pkg.version) ? pkg : null);
    406     return;
    407   }
    408   // Debian, Ubuntu and RHEL packages keep their configuration under /etc. A PHP configured elsewhere
    409   // (/usr/local/etc/php in the official Docker images, a control panel's or a self-built prefix) is not
    410   // a distribution package, so installed packages say nothing about it.
    411   if (!build && obs.configPath && !/^\/etc(\/|$)/.test(obs.configPath) && (debLike || d.family === 'rhel' || !d.family)) {
    412     a.notes.push(t('note.configOutsideEtc', { path: obs.configPath }));
    413     judgeUpstream(ctx, a, ver);
    414     return;
    415   }
    416   if (debLike) {
    417     judgeDeb(ctx, a, obs, ver, build, d, pkg);
    418     return;
    419   }
    420   if (d.family === 'rhel') {
    421     judgeRhel(ctx, a, obs, ver, build, d);
    422     return;
    423   }
    424   if (d.family === 'alpine') {
    425     a.notes.push(t('note.alpine'));
    426     judgeUpstream(ctx, a, ver);
    427     a.path = 'alpine';
    428     return;
    429   }
    430   if (d.family === 'other') {
    431     if (BACKPORTING_VENDORS.test(d.vendor || '')) {
    432       // SUSE keeps the upstream version of its PHP packages and backports fixes into them: a version below the
    433       // upstream fix may have them, and only the vendor's advisories or the package changelog can tell.
    434       a.path = 'other';
    435       a.need = 'vendor';
    436       a.reasons.push(t('reason.vendorBackports', { vendor: obs.os?.pretty || d.vendor, version: ver.raw }));
    437       return;
    438     }
    439     a.notes.push(t('note.otherDistro', { vendor: d.vendor || 'other' }));
    440     judgeUpstream(ctx, a, ver);
    441     a.path = 'other';
    442     return;
    443   }
    444   if (!d.family) {
    445     // The probe could not read the OS release: this may be a RHEL-family or other distribution package whose
    446     // version number stays the same when a fix is backported, so an upstream-looking version is not Vulnerable.
    447     // The distribution is unknown. Do not judge on the upstream version when this could be a
    448     // distribution package that has the fix backported under the same version number.
    449     const releases = sameUpstreamReleases(ctx, ver);
    450     if (obs.osBlocked || releases.length) {
    451       a.path = 'undetermined';
    452       a.need = 'serverOrDistro';
    453       if (obs.osBlocked) a.reasons.push(t(obs.openBasedir ? 'reason.osBlocked' : 'reason.osUnread', { version: ver.raw }));
    454       if (releases.length) a.reasons.push(t('reason.couldBeDistro', { version: ver.raw, releases: releases.join(', ') }));
    455       return;
    456     }
    457   }
    458   judgeUpstream(ctx, a, ver);
    459 }
    460 
    461 // Distributions outside the data that keep the upstream version of their PHP packages and backport fixes into them.
    462 const BACKPORTING_VENDORS = /^(sles|sled|sles_sap|opensuse-leap)$/;
    463 
    464 // deb.sury.org follows upstream releases on supported branches. For end-of-life branches it ships backported fixes
    465 // under the same upstream version (the 8.1.34-9 changelog has "Backported from 8.2.35"), so there the changelog of
    466 // exactly the installed build decides. A Debian build dated before the upstream fix release cannot have them.
    467 function judgeSury(ctx, a, obs, ver, build, pkg) {
    468   const { t, cves, upstream } = ctx;
    469   const version = pkg ? pkg.version : ver.raw;
    470   if (pkg) a.usedKeys.push(buildKey(pkg));
    471   if (upstream.fixed[ver.branch] || comparePhp(ver, '5.4.0') < 0) {
    472     a.notes.push(t('note.sury', { version }));
    473     judgeUpstream(ctx, a, ver);
    474     a.path = 'sury';
    475     return;
    476   }
    477   a.path = 'sury';
    478   a.notes.push(t('note.suryEol', { version, branch: ver.branch }));
    479   const evidence = obs.packages.filter((p) => p.manager === 'dpkg' && debSource(p) === srcFor(ver) && sameDeb(p.version, version));
    480   const logs = evidence.filter((p) => p.changelog);
    481   const found = changelogFound(evidence, cves);
    482   if (found && found.length === cves.length) {
    483     a.verdict = 'patched';
    484     a.reasons.push(t('reason.suryChangelogFixed', { pkg: pkgLabel(logs[0]), cves: cves.join(', ') }));
    485     // PHP_VERSION with the deb.sury.org revision is exactly that build; a plain one rests on the installed package.
    486     a.proven = !build && Boolean(ver.revision) && sameDeb(ver.raw, version);
    487     a.restsOnPackage = !a.proven;
    488     return;
    489   }
    490   const missing = cves.filter((c) => logs.some((p) => p.changelog[c] === 0));
    491   const date = suryBuildDate(version);
    492   if (missing.length || (date && upstream.released && date < upstream.released)) {
    493     judgeUpstream(ctx, a, ver);
    494     a.path = 'sury';
    495     a.reasons.push(missing.length ? t('reason.suryChangelogMissing', { pkg: pkgLabel(logs[0]), cves: missing.join(', ') })
    496       : t('reason.suryBuiltBefore', { version, date: formatDate(date, ctx.lang), released: formatDate(upstream.released, ctx.lang) }));
    497     return;
    498   }
    499   a.verdict = 'unknown';
    500   a.need = logs.length ? 'whole' : 'server';
    501   a.reasons.push(t('reason.suryEolUnread', { version, branch: ver.branch }));
    502 }
    503 
    504 function judgeUpstream(ctx, a, ver) {
    505   const { t, upstream } = ctx;
    506   a.path = 'upstream';
    507   const fixedUp = upstream.fixed[ver.branch];
    508   if (fixedUp) {
    509     a.verdict = 'vulnerable';
    510     a.reasons.push(t('reason.upstreamVulnerable', { version: ver.upstream, fixed: fixedUp }));
    511     return;
    512   }
    513   if (comparePhp(ver, '5.4.0') < 0) {
    514     a.verdict = 'unknown';
    515     a.reasons.push(t('reason.predates54', { version: ver.upstream }));
    516     return;
    517   }
    518   a.verdict = 'vulnerable';
    519   a.eol = true;
    520   a.reasons.push(t('reason.eol', { branch: ver.branch }));
    521   const branch = upstream.backport_branches?.[ver.branch];
    522   if (branch) a.notes.push(t('note.backportBranch', { branch }));
    523 }
    524 
    525 function judgeDeb(ctx, a, obs, ver, build, d, pkg) {
    526   const { t, distro, cves, upstream } = ctx;
    527   const family = d.family === 'debian' ? 'Debian' : 'Ubuntu';
    528   a.path = 'deb';
    529   // PHP_VERSION with a distribution revision identifies the running build exactly.
    530   const running = !build && ver.revision ? ver.raw : null;
    531   // A package older than the running PHP is not what runs (a self-built, panel or container PHP next to it).
    532   if (pkg && !build && cmpUp(debUpstream(pkg.version), ver.upstream) < 0) {
    533     a.notes.push(t('note.packageNotRunning', { pkg: pkgLabel(pkg), version: ver.raw }));
    534     pkg = null;
    535   }
    536   if (!pkg && !running) {
    537     judgeDebVersionOnly(ctx, a, ver, d, family);
    538     return;
    539   }
    540   if (pkg) a.usedKeys.push(buildKey(pkg));
    541 
    542   const src = pkg ? debSource(pkg) : srcFor(ver);
    543   const installed = pkg ? pkg.version : null;
    544   const judged = running || installed;
    545   const fixedUp = upstream.fixed[ver.branch];
    546 
    547   // When the distribution data cannot decide, an installed package that already has the upstream fix shows a
    548   // missed restart; otherwise fall back to the changelog of exactly the build being judged.
    549   const evidence = obs.packages.filter((p) => p.manager === 'dpkg' && debSource(p) === src && sameDeb(p.version, judged));
    550   const noData = (reason) => {
    551     if (!build && installed && fixedUp && cmpUp(debUpstream(installed), fixedUp) >= 0) {
    552       a.verdict = 'restart';
    553       a.reasons.push(t('reason.restartInstalledFixed', { installed, running: running || ver.upstream }));
    554       return;
    555     }
    556     const found = changelogFound(evidence, cves);
    557     if (found && found.length === cves.length) {
    558       a.verdict = 'patched';
    559       a.reasons.push(t('reason.noDataChangelogFixed', { reason }));
    560       a.proven = Boolean(running);
    561       a.restsOnPackage = !running;
    562     } else {
    563       a.verdict = 'unknown';
    564       a.reasons.push(reason);
    565     }
    566   };
    567   if (!d.release) {
    568     a.need = 'serverOrDistro';
    569     noData(t('reason.debReleaseUnknown', { family }));
    570     return;
    571   }
    572   const rel = own(distro[d.family], d.release);
    573   if (!rel) {
    574     a.need = 'advisories';
    575     noData(t('reason.debNoRelease', { family, release: d.release }));
    576     return;
    577   }
    578   const entry = rel.packages?.[src];
    579   if (!entry) {
    580     a.need = 'advisories';
    581     noData(t('reason.debNoPackage', { src, family, release: d.release }));
    582     return;
    583   }
    584 
    585   // A build newer upstream than the release's fixed package (and still below the upstream fix) is not
    586   // that release's package: another repository, a PPA, or a package from another release.
    587   const distroUp = entryUpstream(entry, cves);
    588   const judgedUp = debUpstream(judged);
    589   if (distroUp && cmpUp(judgedUp, distroUp) > 0 && !(fixedUp && cmpUp(judgedUp, fixedUp) >= 0)) {
    590     a.notes.push(t('note.notDistroBuild', { version: judged, family, release: d.release, src, shipped: distroUp }));
    591     judgeUpstream(ctx, a, ver);
    592     return;
    593   }
    594 
    595   if (pkg) a.facts.push({ label: t('fact.packageChecked'), value: pkgLabel(pkg) });
    596   const states = cves.map((cve) => {
    597     const fixed = entry.fixed?.[cve];
    598     if (fixed === undefined) return { cve, state: 'nodata' };
    599     if (fixed === null) return { cve, state: 'pending' };
    600     return { cve, fixed, state: debAtLeast(judged, fixed) ? 'fixed' : 'vulnerable' };
    601   });
    602   pushDebAdvisories(t, a, family, d.release, src, entry, cves);
    603   if (entry.pro) a.notes.push(t(UBUNTU_LEGACY.includes(d.release) ? 'note.ubuntuProLegacy' : 'note.ubuntuPro'));
    604 
    605   // The same upstream version as the release's fixed package, below the upstream fix: the fix is a backport,
    606   // which only the distribution's own builds carry. A revision of another vendor that merely sorts above the
    607   // fixed one ("8.3.6-1+ppa1", "8.1.2-2", a Debian "+deb9u12" judged as Ubuntu) is not that backport.
    608   const backport = distroUp && cmpUp(judgedUp, distroUp) === 0 && !(fixedUp && cmpUp(judgedUp, fixedUp) >= 0);
    609   if (backport && states.every((s) => s.state === 'fixed') && !vendorBuild(d.family, judged)) {
    610     a.verdict = 'unknown';
    611     a.need = 'supplier';
    612     a.reasons.push(t('reason.foreignRevision', { version: judged, family, release: d.release, src }));
    613     return;
    614   }
    615 
    616   if (states.every((s) => s.state === 'fixed')) {
    617     a.verdict = 'patched';
    618     a.reasons.push(t('reason.debBackported', { family, src, version: judged, upstream: ver.upstream }));
    619     a.proven = Boolean(running);
    620     a.restsOnPackage = !running;
    621   } else if (states.some((s) => s.state === 'vulnerable')) {
    622     a.verdict = 'vulnerable';
    623     a.reasons.push(t('reason.debOlder', { src, version: judged, family, fixed: maxFixed(states) }));
    624   } else if (states.some((s) => s.state === 'pending')) {
    625     a.verdict = 'pending';
    626     a.reasons.push(t('reason.debPending', { src, family, release: d.release }));
    627   } else {
    628     a.verdict = 'unknown';
    629     a.need = 'advisories';
    630     a.reasons.push(t('reason.debNoCveData'));
    631   }
    632 
    633   // Running and installed versions differ.
    634   const fixedFor = (v) => states.every((s) => s.fixed && debAtLeast(v, s.fixed));
    635   const order = running && installed ? compareDebian(debComparable(installed), debComparable(running)) : 0;
    636   if (order > 0) {
    637     // A newer package is installed: the service was not restarted after an update.
    638     if (fixedFor(running)) {
    639       // The running build already has the fix; the newer package only waits for a restart.
    640       a.notes.push(t('note.newerPackageWaiting', { installed, running }));
    641     } else if (fixedFor(installed)) {
    642       a.verdict = 'restart';
    643       a.reasons = [t('reason.restartInstalledFixed', { installed, running })];
    644     } else {
    645       a.reasons.push(t('reason.installedNewer', { installed, running }));
    646     }
    647   } else if (order < 0 && a.verdict === 'patched' && !fixedFor(installed)) {
    648     // An older package is installed (a downgrade): the next restart loads the vulnerable build.
    649     a.verdict = 'vulnerable';
    650     a.downgraded = true;
    651     a.reasons.push(t('reason.installedOlderUnfixed', { installed: pkgLabel(pkg), running }));
    652   } else if (!running && installed && !build && cmpUp(debUpstream(installed), ver.upstream) > 0) {
    653     // PHP_VERSION without a revision: only the upstream parts can be compared.
    654     a.reasons.push(t('reason.installedNewer', { installed, running: ver.upstream }));
    655     if (a.verdict === 'patched') {
    656       a.verdict = 'restart';
    657       a.restsOnPackage = false;
    658     }
    659   }
    660   if (!build && !running && pkg && a.verdict === 'patched') checkPlainVersion(ctx, a, obs, ver, d, family, pkg);
    661 }
    662 
    663 // The PHP probe's PHP_VERSION has no revision, and the verdict rests on the installed package of its branch.
    664 // - Debian and Ubuntu build the PHP engine into each SAPI's own binary, so the package of the SAPI that runs
    665 //   (php8.3-fpm, libapache2-mod-php8.3) must be installed: the other packages of the branch do not show which build
    666 //   that binary is. dpkg lists only fully installed packages, so one that an interrupted update left half-configured
    667 //   is missing from the list.
    668 // - On Ubuntu 22.04 and older, the security updates report their revision in PHP_VERSION (8.1.2-1ubuntu2.28); only
    669 //   the release's original build reports the plain version. A plain version next to an updated package is therefore
    670 //   the original build, loaded before the update.
    671 function checkPlainVersion(ctx, a, obs, ver, d, family, pkg) {
    672   const { t } = ctx;
    673   const sapiName = sapiPackageName(ver, ctx.sapi);
    674   const complete = obs.serverData || (obs.probeDpkg !== null && obs.probeDpkg.length < PROBE_DPKG_MAX);
    675   if (sapiName && ctx.sapi !== 'cli' && complete && !obs.packages.some((p) => p.manager === 'dpkg' && p.name === sapiName)) {
    676     a.verdict = 'unknown';
    677     a.restsOnPackage = false;
    678     a.need = 'sapiPackage';
    679     a.sapiPackage = sapiName;
    680     a.reasons = [t('reason.sapiPackageMissing', { sapi: sapiLabel(ctx.sapi, ctx.lang), pkg: sapiName, src: srcFor(ver) })];
    681     return;
    682   }
    683   if (d.family === 'ubuntu' && UBUNTU_REVISION_IN_VERSION.includes(d.release) && /ubuntu\d+\./.test(splitDebian(debComparable(pkg.version)).revision)) {
    684     a.verdict = 'restart';
    685     a.restsOnPackage = false;
    686     a.reasons = [t('reason.revisionMissing', { version: ver.raw, family, release: d.release, installed: pkgLabel(pkg), expected: debComparable(pkg.version) })];
    687   }
    688 }
    689 
    690 // A revision the distribution itself builds: Ubuntu's carry "ubuntu", Debian's stable updates "debNNuN".
    691 function vendorBuild(family, version) {
    692   const { revision } = splitDebian(debComparable(version));
    693   return family === 'ubuntu' ? /ubuntu/.test(revision) : /deb\d+u\d+/.test(revision);
    694 }
    695 
    696 // Debian or Ubuntu, with no distribution revision in PHP_VERSION and no package of this branch to compare.
    697 function judgeDebVersionOnly(ctx, a, ver, d, family) {
    698   const { t, distro, cves } = ctx;
    699   const src = srcFor(ver);
    700   if (!d.release) {
    701     const releases = Object.keys(distro[d.family] || {}).filter((rel) => entryUpstream(distro[d.family][rel].packages?.[src], cves) === ver.upstream);
    702     if (releases.length) {
    703       a.need = 'serverOrDistro';
    704       a.reasons.push(t('reason.debReleaseUnknown', { family }));
    705       return;
    706     }
    707     a.notes.push(t('note.upstreamBuild'));
    708     judgeUpstream(ctx, a, ver);
    709     return;
    710   }
    711   const rel = own(distro[d.family], d.release);
    712   if (!rel) {
    713     a.need = 'advisories';
    714     a.reasons.push(t('reason.debNoRelease', { family, release: d.release }));
    715     return;
    716   }
    717   const entry = rel.packages?.[src];
    718   if (!entry) {
    719     // The release does not ship this source package, so this PHP comes from somewhere else.
    720     a.notes.push(t('note.upstreamBuild'));
    721     judgeUpstream(ctx, a, ver);
    722     return;
    723   }
    724   const distroUp = entryUpstream(entry, cves);
    725   if (distroUp && cmpUp(ver.upstream, distroUp) > 0) {
    726     a.notes.push(t('note.notDistroBuild', { version: ver.raw, family, release: d.release, src, shipped: distroUp }));
    727     judgeUpstream(ctx, a, ver);
    728     return;
    729   }
    730   pushDebAdvisories(t, a, family, d.release, src, entry, cves);
    731   if (entry.pro) a.notes.push(t(UBUNTU_LEGACY.includes(d.release) ? 'note.ubuntuProLegacy' : 'note.ubuntuPro'));
    732   if (!distroUp) {
    733     const pending = cves.some((c) => entry.fixed?.[c] === null);
    734     a.verdict = pending ? 'pending' : 'unknown';
    735     if (!pending) a.need = 'advisories';
    736     a.reasons.push(pending ? t('reason.debPending', { src, family, release: d.release }) : t('reason.debNoCveData'));
    737     return;
    738   }
    739   const fixed = cves.map((c) => entry.fixed?.[c]).filter(Boolean).sort(compareDebian).pop();
    740   if (cmpUp(ver.upstream, distroUp) < 0) {
    741     a.verdict = 'vulnerable';
    742     a.reasons.push(t('reason.debOlderUpstream', { version: ver.raw, family, release: d.release, src, fixed }));
    743     return;
    744   }
    745   // Same upstream version before and after the fix: only the package version can tell.
    746   a.need = 'server';
    747   a.reasons.push(t('reason.debNeedPackage', { version: ver.raw, family, release: d.release, src }));
    748 }
    749 
    750 function pushDebAdvisories(t, a, family, release, src, entry, cves) {
    751   for (const cve of cves) {
    752     const fixed = entry.fixed?.[cve];
    753     a.advisories.push({
    754       label: `${family} ${release} ${src} · ${cve}`,
    755       value: fixed ? t('advisory.fixedIn', { version: fixed }) : fixed === null ? t('advisory.pending') : t('advisory.noData'),
    756     });
    757   }
    758 }
    759 
    760 function judgeRhel(ctx, a, obs, ver, build, d) {
    761   const { t, distro, cves } = ctx;
    762   a.path = 'rhel';
    763   const rh = d.release ? own(distro.redhat, d.release) : null;
    764   for (const x of rh?.advisories || []) {
    765     if (!x?.cve || !x.advisory) continue;
    766     a.advisories.push({ label: `RHEL ${d.release} · ${x.cve}`, value: x.package ? `${x.advisory} (${x.package})` : String(x.advisory) });
    767   }
    768   for (const s of rh?.states || []) {
    769     if (!s?.cve || !s.state) continue;
    770     const state = own(RHEL_STATES, s.state) ? t(RHEL_STATES[s.state]) : String(s.state);
    771     a.advisories.push({ label: `RHEL ${d.release} · ${s.cve}`, value: s.package ? `${s.package}: ${state}` : state });
    772   }
    773   if (d.vendor && d.vendor !== 'rhel') a.notes.push(t('note.rhelCompatible'));
    774 
    775   // Remi's software collections live under /etc/opt/remi.
    776   const remiPath = !build && /^\/etc\/opt\/remi\//.test(obs.configPath || '');
    777   const rpms = obs.packages.filter((p) => p.manager === 'rpm');
    778   // The packages of the PHP being judged: the same upstream version, and the same layout as the probe's config path.
    779   let candidates = build ? build.packages : rpms.filter((p) => debUpstream(p.version) === ver.upstream);
    780   if (!build && obs.configPath && candidates.length > 1) {
    781     const scl = /^\/etc\/opt\/remi\/(php\d+)(\/|$)/.exec(obs.configPath);
    782     const narrowed = candidates.filter((p) => (scl ? p.name.startsWith(`${scl[1]}-`) : !/^php\d+-php(-|$)/.test(p.name)));
    783     if (narrowed.length) candidates = narrowed;
    784   }
    785   if (candidates.length) a.usedKeys.push(...new Set(candidates.map(buildKey)));
    786   // Every package of this PHP whose changelog was read must record the fix. A package with no changelog lines was
    787   // not checked, which is not the same as a count of 0; it is covered only by a package of exactly the same version,
    788   // whose changelog is the same (every package built from one source package carries its changelog).
    789   const logged = candidates.filter((p) => p.changelog);
    790   const unlogged = candidates.filter((p) => !p.changelog && !logged.some((q) => q.version === p.version));
    791   const status = Object.fromEntries(cves.map((cve) => [cve, changelogStatus(logged, unlogged, cve)]));
    792   const found = cves.filter((cve) => status[cve] === 'found');
    793   const names = logged.map(pkgLabel).join(', ');
    794 
    795   if (remiPath || (candidates.length && candidates.every(isRemi))) {
    796     a.path = 'remi';
    797     const pkg = candidates.length ? pkgLabel(candidates[0]) : obs.configPath;
    798     // Remi follows upstream releases, but its collections of end-of-life branches (php74, php80, php81) carry
    799     // backported fixes under the same version number, and their changelogs record them.
    800     if (found.length === cves.length) {
    801       a.verdict = 'patched';
    802       a.restsOnPackage = true;
    803       a.reasons.push(t('reason.remiChangelogFixed', { pkg: names, cves: found.join(', ') }));
    804       return;
    805     }
    806     if (!ctx.upstream.fixed[ver.branch] && comparePhp(ver, '5.4.0') >= 0) {
    807       // An end-of-life collection: only its changelog can show the backport. A changelog that was read and has no
    808       // entry (a count of 0) shows a build without it; a changelog that was not read shows nothing.
    809       a.notes.push(t('note.remiEol', { pkg }));
    810       const missing = cves.filter((cve) => status[cve] === 'missing');
    811       if (missing.length) {
    812         judgeUpstream(ctx, a, ver);
    813         a.path = 'remi';
    814         a.reasons.push(t('reason.remiChangelogMissing', { pkg: names, cves: missing.join(', ') }));
    815         return;
    816       }
    817       a.verdict = 'unknown';
    818       a.need = logged.length ? 'whole' : 'server';
    819       if (logged.length) {
    820         a.reasons.push(t('reason.rhelChangelogUnread', { pkg: (unlogged.length ? unlogged : logged).map(pkgLabel).join(', '), cves: cves.filter((c) => status[c] !== 'found').join(', ') }));
    821       } else {
    822         a.reasons.push(obs.serverData && candidates.length ? t('reason.rhelChangelogNotChecked', { pkg }) : t('reason.remiEolUnread', { pkg, branch: ver.branch }));
    823       }
    824       return;
    825     }
    826     a.notes.push(t('note.remi', { pkg }));
    827     judgeUpstream(ctx, a, ver);
    828     a.path = 'remi';
    829     return;
    830   }
    831   if (!obs.serverData) {
    832     a.need = 'server';
    833     a.reasons.push(t('reason.rhelNeedServerScript'));
    834     return;
    835   }
    836   if (!rpms.length) {
    837     // No PHP installed with rpm at all: this PHP was built from source or comes from a panel.
    838     a.notes.push(t('note.noRpmPackage'));
    839     judgeUpstream(ctx, a, ver);
    840     return;
    841   }
    842   if (!candidates.length) {
    843     a.need = 'elsewhere';
    844     a.reasons.push(t('reason.rhelNoMatchingPackage', { version: ver.upstream, packages: rpms.map(pkgLabel).join(', ') }));
    845     return;
    846   }
    847   if (!logged.length) {
    848     a.need = 'server';
    849     a.reasons.push(t('reason.rhelChangelogNotChecked', { pkg: pkgLabel(candidates[0]) }));
    850     return;
    851   }
    852   const notAffected = cves.filter((cve) => !found.includes(cve) && rhelNotAffected(rh, d.release, cve, ver));
    853   const missing = cves.filter((cve) => status[cve] === 'missing' && !notAffected.includes(cve));
    854   const unread = cves.filter((cve) => status[cve] === 'unread' && !notAffected.includes(cve));
    855   if (!missing.length && !unread.length) {
    856     a.verdict = 'patched';
    857     a.restsOnPackage = true;
    858     if (found.length) a.reasons.push(t('reason.rhelChangelogFixed', { pkg: names, cves: found.join(', ') }));
    859   } else {
    860     if (missing.length) a.reasons.push(t('reason.rhelChangelogMissing', { pkg: names, cves: missing.join(', ') }));
    861     if (unread.length) {
    862       if (!missing.length) a.need = 'whole';
    863       a.reasons.push(t('reason.rhelChangelogUnread', { pkg: (unlogged.length ? unlogged : logged).map(pkgLabel).join(', '), cves: unread.join(', ') }));
    864     }
    865     // A changelog that was read and has no entry, on a build older than the one that fixes it, is not ambiguous.
    866     if (missing.length) rhelOlderThanFix(ctx, a, ver, d, rh, logged);
    867   }
    868   if (notAffected.length) a.reasons.push(t('reason.rhelNotAffected', { cves: notAffected.join(', '), release: d.release }));
    869 }
    870 
    871 // Vulnerable when the installed build is older than the fixed build: the exact build a Red Hat advisory names for this
    872 // branch and release (php-0:8.0.30-8.el9_8.1, php8.4-0:8.4.27-1.el10_2), or, on Amazon Linux, which ships its PHP
    873 // fixes as upstream releases, the upstream fixed release. A module stream advisory names no package version, so a
    874 // module build stays Undetermined.
    875 function rhelOlderThanFix(ctx, a, ver, d, rh, logged) {
    876   const { t } = ctx;
    877   const main = logged.find((p) => /-common$/.test(p.name)) || logged[0];
    878   if (!main) return;
    879   if (d.vendor === 'amzn') {
    880     const fixedUp = ctx.upstream.fixed[ver.branch];
    881     if (fixedUp && cmpUp(debUpstream(main.version), fixedUp) < 0) {
    882       a.verdict = 'vulnerable';
    883       a.reasons.push(t('reason.amazonOlder', { pkg: pkgLabel(main), fixed: fixedUp }));
    884     }
    885     return;
    886   }
    887   const builds = (rh?.advisories || [])
    888     .map((x) => ({ advisory: x?.advisory, m: /^php[\d.]*-((?:\d+:)?\d+\.\d+\.\d+-.+)$/.exec(x?.package || '') }))
    889     .filter((x) => x.advisory && x.m && advisoryBranch(x.m[0]) === ver.branch)
    890     .sort((x, y) => compareRpm(x.m[1], y.m[1]));
    891   const fixed = builds[builds.length - 1];
    892   if (fixed && compareRpm(main.version, fixed.m[1]) < 0) {
    893     a.verdict = 'vulnerable';
    894     a.reasons.push(t('reason.rhelOlderThanFixed', { pkg: pkgLabel(main), fixed: fixed.m[1].replace(/^0:/, ''), advisory: fixed.advisory }));
    895   }
    896 }
    897 
    898 // What the changelogs of one PHP's rpm packages show for a CVE: 'found' (every package records the fix), 'missing'
    899 // (a package was read and has no entry: a count of 0), or 'unread' (a count is unknown, or a package was not read).
    900 function changelogStatus(logged, unlogged, cve) {
    901   if (!logged.length) return 'unread';
    902   const counts = logged.map((p) => (typeof p.changelog[cve] === 'number' ? p.changelog[cve] : null));
    903   if (counts.some((n) => n === 0)) return 'missing';
    904   if (counts.every((n) => n > 0)) return unlogged.length ? 'unread' : 'found';
    905   return 'unread';
    906 }
    907 
    908 // Red Hat lists the CVE as "Not affected" for this release and this PHP. A module stream state applies to its
    909 // branch only, and a state for the plain "php" package to the branch of Red Hat's own php package in that release.
    910 function rhelNotAffected(rh, release, cve, ver) {
    911   return (rh?.states || []).some((s) => {
    912     if (s.cve !== cve || s.state !== 'Not affected') return false;
    913     const stream = /^php:(\d+\.\d+)(\/|$)/.exec(s.package) || /^php(\d+\.\d+)$/.exec(s.package);
    914     if (stream) return stream[1] === ver.branch;
    915     return s.package === 'php' && rhelBaseBranch(rh, release) === ver.branch;
    916   });
    917 }
    918 
    919 // The branch of Red Hat's own php package: from an advisory for it (php-0:8.0.30-8.el9_8.1), else the table.
    920 function rhelBaseBranch(rh, release) {
    921   for (const x of rh?.advisories || []) {
    922     const m = /^php-(?:\d+:)?(\d+\.\d+)\./.exec(x.package || '');
    923     if (m) return m[1];
    924   }
    925   return RHEL_BASE_BRANCH[release] || null;
    926 }
    927 
    928 function isRemi(p) {
    929   return /\.remi(\.|$)/.test(p.version) || /^php\d+-php(-|$)/.test(p.name);
    930 }
    931 
    932 // Was the PHP that was judged restarted after its last update, and was that checked at all?
    933 // mine: the processes the server script saw that may run this PHP.
    934 function checkRunning(ctx, a, obs, ver, build, mine) {
    935   const { t } = ctx;
    936   const stale = mine.filter((p) => p.status === 'stale');
    937   if (a.downgraded) {
    938     // The running build has the fix and the package on disk is older: stale processes still run that fixed build,
    939     // and a restart would replace it with the vulnerable one.
    940     if (stale.length) a.notes.push(t('note.staleDowngraded', { names: commNames(stale) }));
    941     return;
    942   }
    943   if (stale.length) {
    944     // The probe's own PHP_VERSION shows the fix, and the stale processes are that same SAPI and branch: what runs has
    945     // the fix, and only a newer package waits to be loaded. One PHP-FPM master and its workers share their memory
    946     // maps, so they are all stale or all current: a stale process next to a current one of the same name is another
    947     // PHP, which the probe does not vouch for.
    948     const procs = obs.processes || [];
    949     const self = a.proven && obs.sapi && stale.every((p) => commSapi(p.comm) === sapiKind(obs.sapi)
    950       && !procs.some((q) => q.comm === p.comm && q.status === 'ok'));
    951     if (a.verdict === 'patched' && self) {
    952       a.notes.push(t('note.staleFixed', { names: commNames(stale) }));
    953     } else if (a.verdict === 'patched' || a.verdict === 'restart') {
    954       a.reasons.push(t(a.proven ? 'reason.staleOtherProcesses' : 'reason.staleProcesses', { names: commNames(stale) }));
    955       if (a.proven) a.staleOther = true;
    956       a.verdict = 'restart';
    957     } else {
    958       // The build installed now is not shown to have the fix, so a restart alone would not help: it would load that
    959       // build, and if the files were replaced by a downgrade, it would replace a fixed build with it.
    960       a.reasons.push(t('reason.staleUnfixed', { names: commNames(stale) }));
    961     }
    962     return;
    963   }
    964   // -v output of php-fpm or php-cgi shows the binary on disk, not what the running service loaded.
    965   if (!build && a.verdict === 'patched' && obs.sources.includes('cli') && obs.sapi && obs.sapi !== 'cli') {
    966     a.reasons.push(t('reason.binaryOnDisk', { sapi: obs.sapi }));
    967     return;
    968   }
    969   if (a.verdict !== 'patched' || !a.restsOnPackage) return;
    970   if (obs.serverData) {
    971     const unseen = build ? unseenServices(build, mine) : [];
    972     if (obs.procUnreadable > 0) {
    973       a.verdict = 'unknown';
    974       a.need = 'root';
    975       a.reasons.push(t(obs.root === true ? 'reason.runningUncheckedRoot' : 'reason.runningUnchecked'));
    976     } else if (obs.root === false && (!build || servesWeb(build))) {
    977       // Without root, /proc mounted with hidepid hides other accounts' processes without a trace: the processes the
    978       // script saw, if any, are not all that may run this build.
    979       a.verdict = 'unknown';
    980       a.need = 'root';
    981       a.reasons.push(t(mine.length ? 'reason.runningPartlyHidden' : 'reason.runningHidden'));
    982     } else if (unseen.length) {
    983       a.verdict = 'unknown';
    984       a.need = 'unseen';
    985       a.unseen = unseen;
    986       a.reasons.push(t('reason.serviceUnseen', { names: unseen.join(', ') }));
    987     } else if (!mine.length) {
    988       if (obs.root === false) {
    989         // No proc lines without root mean the processes were not checked, not that none run. A build with no web
    990         // service package cannot keep an old build loaded in a web server, so the installed package decides.
    991         a.reasons.push(t('reason.processesNotCheckedNoService'));
    992       } else {
    993         a.reasons.push(build ? t('reason.noRunningPhpSeen') : t('reason.probeProcessNotFound', { version: ver.raw }));
    994       }
    995     }
    996   } else if (obs.serverConflict) {
    997     a.verdict = 'unknown';
    998     a.need = 'together';
    999     a.reasons.push(t('reason.serverConflict'));
   1000   } else {
   1001     a.reasons.push(t('reason.loadedBuildUnconfirmed', { version: ver.raw }));
   1002   }
   1003 }
   1004 
   1005 // The packages of a build whose processes the server script cannot see in /proc, when it saw none of them: php5-fpm
   1006 // and php5-cgi (Ubuntu 14.04 and Debian 8 name the processes after the package, which the script's process list
   1007 // does not include), and an Alpine Apache module (mod_php83.so, which the script's libphp test does not match).
   1008 function unseenServices(build, mine) {
   1009   const manager = build.packages[0]?.manager;
   1010   const out = [];
   1011   for (const p of build.packages) {
   1012     if (manager === 'dpkg' && /^php5-(fpm|cgi)$/.test(p.name) && !mine.some((q) => q.comm === p.name)) out.push(p.name);
   1013     if (manager === 'apk' && /-apache2$/.test(p.name) && !mine.some((q) => q.comm === 'httpd' || q.comm === 'apache2')) out.push(p.name);
   1014   }
   1015   return [...new Set(out)];
   1016 }
   1017 
   1018 // Whether process p may run the PHP of result x. The running PHP judged with an installed build runs what that
   1019 // build can run; otherwise any process of its branch may be it.
   1020 function mayRun(p, x, installed) {
   1021   if (x.build) return procRunsBuild(p, x.build);
   1022   const used = installed.filter((b) => x.a.usedKeys.includes(b.key));
   1023   return used.length ? used.some((b) => procRunsBuild(p, b)) : procMayRun(p.comm, x.ver);
   1024 }
   1025 
   1026 // Whether a process the server script saw can run this installed build, by how each packaging names it.
   1027 // Debian and Ubuntu name FPM and CGI after the branch (php-fpm8.2, from the php8.2-fpm package; php5-fpm on the
   1028 // oldest releases) and run PHP in Apache only through a libapache2-mod-php package. Each SAPI binary carries the
   1029 // whole PHP engine, so a process belongs to a build only when that build has the SAPI's own package. Alpine names
   1030 // them php-fpm83. On the RHEL family every collection runs as php-fpm or in httpd: php-fpm from a php-fpm or
   1031 // php8.4-fpm package, httpd from php or mod_php, or php8.4-modphp on Amazon Linux 2023. An unversioned php-fpm on
   1032 // Debian is a container's, a panel's or a self-built PHP.
   1033 function procRunsBuild(p, build) {
   1034   const comm = p.comm;
   1035   const names = build.packages.map((x) => x.name);
   1036   const branch = build.ver.branch;
   1037   switch (build.packages[0]?.manager) {
   1038     case 'dpkg': {
   1039       const m = /^php-(fpm|cgi)(\d+\.\d+)$/.exec(comm);
   1040       if (m) return m[2] === branch && names.includes(`php${branch}-${m[1]}`);
   1041       if (/^php5-(fpm|cgi)$/.test(comm)) return names.includes(comm);
   1042       return comm === 'apache2' && names.some((n) => /^libapache2-mod-php/.test(n));
   1043     }
   1044     case 'apk': {
   1045       const m = /^php-(fpm|cgi)(\d)(\d+)$/.exec(comm);
   1046       if (m) return `${m[2]}.${m[3]}` === branch && names.some((n) => n.endsWith(`-${m[1]}`));
   1047       return (comm === 'httpd' || comm === 'apache2') && names.some((n) => /-apache2$/.test(n));
   1048     }
   1049     case 'rpm':
   1050       if (comm === 'php-fpm') return names.some((n) => /(^|-)php(\d+\.\d+)?-fpm$/.test(n));
   1051       // A collection's Apache module package (php74-php) is not in the server script's list.
   1052       if (comm === 'httpd') return names.some((n) => n === 'php' || n === 'mod_php' || /-modphp$/.test(n)) || /^rpm:php\d+$/.test(build.key);
   1053       if (comm === 'php-cgi') return names.some((n) => /(^|-)php(\d+\.\d+)?-(cli|cgi)$/.test(n));
   1054       return false;
   1055     default:
   1056       return procMayRun(comm, build.ver);
   1057   }
   1058 }
   1059 
   1060 // A process name with a version (php-fpm8.2) runs only that branch; others (apache2, httpd, php-fpm) may run any.
   1061 function procMayRun(comm, ver) {
   1062   const m = /^php-(?:fpm|cgi)(\d+\.\d+)$/.exec(comm);
   1063   return m ? m[1] === ver.branch : true;
   1064 }
   1065 
   1066 function commSapi(comm) {
   1067   if (/^php\d*-fpm/.test(comm)) return 'fpm';
   1068   if (comm === 'apache2' || comm === 'httpd') return 'apache';
   1069   if (/^lsphp/.test(comm)) return 'litespeed';
   1070   if (comm === 'frankenphp') return 'frankenphp';
   1071   if (/^php\d*-cgi/.test(comm)) return 'cgi';
   1072   return comm;
   1073 }
   1074 
   1075 function sapiKind(sapi) {
   1076   return own({ 'fpm-fcgi': 'fpm', apache2handler: 'apache', litespeed: 'litespeed', frankenphp: 'frankenphp', 'cgi-fcgi': 'cgi', cgi: 'cgi' }, sapi) || sapi;
   1077 }
   1078 
   1079 const commNames = (procs) => [...new Set(procs.map((p) => p.comm))].join(', ');
   1080 
   1081 function inferSapi(processes) {
   1082   const comms = (processes || []).map((p) => p.comm);
   1083   if (comms.some((c) => /^php\d*-fpm/.test(c))) return 'fpm-fcgi';
   1084   if (comms.some((c) => c === 'apache2' || c === 'httpd')) return 'apache2handler';
   1085   if (comms.includes('frankenphp')) return 'frankenphp';
   1086   if (comms.some((c) => /^lsphp/.test(c))) return 'litespeed';
   1087   return null;
   1088 }
   1089 
   1090 // Whether a build has a package that runs PHP as a service (FPM, CGI, an Apache module): only such a service can
   1091 // keep an old build loaded in a process the server script checks. A CLI-only build has none.
   1092 function servesWeb(build) {
   1093   return build.packages.some((p) => /-(fpm|cgi|apache2|modphp)$/.test(p.name) || /^libapache2-mod-php/.test(p.name)
   1094     || (p.manager === 'rpm' && /^(php|mod_php)$/.test(p.name)));
   1095 }
   1096 
   1097 // The PHP SAPI an installed build's packages provide, for the restart command.
   1098 function buildSapi(build) {
   1099   const names = build.packages.map((p) => p.name);
   1100   if (names.some((n) => /-fpm$/.test(n))) return 'fpm-fcgi';
   1101   if (names.some((n) => /^libapache2-mod-php|^mod_php$|^php$|-apache2$|-modphp$/.test(n))) return 'apache2handler';
   1102   return null;
   1103 }
   1104 
   1105 // CVEs with a fix recorded in the changelogs of the given packages. null when none of them has a changelog.
   1106 function changelogFound(packages, cves) {
   1107   const logs = packages.map((p) => p.changelog).filter(Boolean);
   1108   if (!logs.length) return null;
   1109   return cves.filter((cve) => logs.some((hits) => hits[cve] > 0));
   1110 }
   1111 
   1112 // The installed PHP builds in the server script output: one per Debian source package, rpm
   1113 // collection or apk prefix (php8.2 and php8.4 side by side are two builds).
   1114 function installedBuilds(obs) {
   1115   const groups = new Map();
   1116   for (const p of obs.packages) {
   1117     const key = buildKey(p);
   1118     if (!groups.has(key)) groups.set(key, []);
   1119     groups.get(key).push(p);
   1120   }
   1121   const builds = [];
   1122   for (const [key, packages] of groups) {
   1123     const main = packages.find((p) => /-common$/.test(p.name)) || packages[0];
   1124     const ver = parsePhpVersion(debComparable(main.version));
   1125     if (ver) builds.push({ key, packages, main, ver });
   1126   }
   1127   return builds.sort((x, y) => compareBranch(x.ver.branch, y.ver.branch) || x.key.localeCompare(y.key));
   1128 }
   1129 
   1130 function buildKey(p) {
   1131   if (p.manager === 'dpkg') return `dpkg:${debSource(p)}`;
   1132   const scl = /^(php\d+)-php(-|$)/.exec(p.name);
   1133   if (p.manager === 'rpm') return `rpm:${scl ? scl[1] : 'php'}`;
   1134   const apk = /^(php\d*)/.exec(p.name);
   1135   return `${p.manager}:${apk ? apk[1] : p.name}`;
   1136 }
   1137 
   1138 // The distribution family whose data the verdict rests on, which the page names as the data source: 'debian',
   1139 // 'ubuntu' or 'rhel'. null when it rests on upstream releases (an upstream or Docker build, a PHP configured outside
   1140 // /etc, deb.sury.org, Remi, Alpine, other distributions) or on no data at all (pre-releases, undetermined builds).
   1141 function dataFamily(d, path) {
   1142   if (path === 'deb' && (d.family === 'debian' || d.family === 'ubuntu')) return d.family;
   1143   if (path === 'rhel' && d.family === 'rhel') return 'rhel';
   1144   return null;
   1145 }
   1146 
   1147 function finish(ctx, r, obs, d, ver, info) {
   1148   const { t, lang } = ctx;
   1149   r.title = t(`verdict.title.${r.verdict}`);
   1150   if (ctx.sapi) r.exposure = sapiExposure(ctx.sapi, lang);
   1151   if (obs.ini) r.mitigations = evaluateMitigations(obs.ini, obs.extensions, ver, r.verdict === 'patched', lang);
   1152   r.findings = describeFindings(t, obs.findings || {}, obs.root);
   1153   buildActions(ctx, r, obs, d || {}, ver, info);
   1154   // The page shows these values as text: never null, a number or an object.
   1155   r.facts = textRows(r.facts);
   1156   r.advisories = textRows(r.advisories);
   1157   return r;
   1158 }
   1159 
   1160 function textRows(rows) {
   1161   return rows
   1162     .filter((x) => x && x.value !== null && x.value !== undefined && String(x.value).trim() !== '')
   1163     .map((x) => ({ label: String(x.label), value: String(x.value) }));
   1164 }
   1165 
   1166 // Results of the server script's checks for signs of compromise (--logs, --webroot).
   1167 // A count of null means the source could not be read; it is never reported as "nothing found".
   1168 function describeFindings(t, f, root) {
   1169   const out = [];
   1170   const keys = Object.keys(FINDING_SOURCE).filter((k) => k in f);
   1171   if (f.oom > 0) out.push({ level: 'warn', text: t('finding.oom', { n: f.oom }) });
   1172   if (f.crash > 0) out.push({ level: 'warn', text: t('finding.crash', { n: f.crash }) });
   1173   if (f.bigsession > 0) out.push({ level: 'warn', text: t('finding.bigSession', { n: f.bigsession }) });
   1174   if (f.webuser_proc > 0) out.push({ level: 'warn', text: t('finding.webUserProc', { n: f.webuser_proc }) });
   1175   if (f.recent_php > 0) out.push({ level: 'warn', text: t('finding.recentPhp', { n: f.recent_php }) });
   1176   // A count of null is "unknown": the source was missing or unreadable. It is never "nothing found".
   1177   const unread = [...new Set(keys.filter((k) => f[k] === null || f[k] === undefined).map((k) => FINDING_SOURCE[k]))];
   1178   const checked = [...new Set(keys.map((k) => FINDING_SOURCE[k]))];
   1179   const sources = (list) => list.map((s) => t(`finding.source.${s}`)).join(', ');
   1180   if (unread.length) {
   1181     out.push({ level: 'info', text: t('finding.unread', { items: sources(unread) }) });
   1182   } else if (keys.length && root === false) {
   1183     out.push({ level: 'info', text: t('finding.notRoot') });
   1184   }
   1185   // Name what was checked: the server script always counts web account processes, and logs, session files and
   1186   // the web root only with --logs and --webroot.
   1187   if (keys.length && !out.length) out.push({ level: 'ok', text: t('finding.none', { items: sources(checked) }) });
   1188   if (out.some((x) => x.level === 'warn' || x.level === 'bad')) {
   1189     out.push({ level: 'info', text: t('finding.incident') });
   1190   }
   1191   return out;
   1192 }
   1193 
   1194 // ── Remediation steps ──────────────────────────────────────────────
   1195 // r.actions is a numbered list of steps, each { text, code? }: a sentence or two of plain explanation and, where
   1196 // useful, commands to copy. Versions, package names, image tags, service names and paths come from the observation
   1197 // and the data files. A placeholder in angle brackets (<container>, <your-image>) stands only for a value the input
   1198 // cannot show. Commands never delete anything and restart only the services that run this PHP.
   1199 
   1200 // Debian releases the official PHP Docker images are still built on (php:8.3.36-apache-bookworm exists,
   1201 // php:8.3.36-apache-bullseye does not), and the Alpine releases their tags name (php:8.4.27-fpm-alpine3.24).
   1202 const DOCKER_DEBIAN = [{ release: 12, codename: 'bookworm' }, { release: 13, codename: 'trixie' }];
   1203 const DOCKER_ALPINE = ['3.23', '3.24'];
   1204 const DEBIAN_RELEASES = { jessie: 8, stretch: 9, buster: 10, bullseye: 11, bookworm: 12, trixie: 13, forky: 14 };
   1205 // Ubuntu releases past their ten-year ESM window: their fixes come only with the paid Legacy Support add-on.
   1206 const UBUNTU_LEGACY = ['14.04', '16.04'];
   1207 // Ubuntu releases whose PHP security updates report their package revision in PHP_VERSION (8.1.2-1ubuntu2.28). From
   1208 // 24.04 on, PHP_VERSION is the plain upstream version before and after the fix.
   1209 const UBUNTU_REVISION_IN_VERSION = ['14.04', '16.04', '18.04', '20.04', '22.04'];
   1210 // The probe lists at most this many packages, so a list of this length may be incomplete.
   1211 const PROBE_DPKG_MAX = 40;
   1212 // Where the page publishes the probe and the server script (the download lines on its tabs).
   1213 // The published download location. The page swaps it for its own address, so a
   1214 // self-hosted copy points at its own downloads/.
   1215 export const DEFAULT_DOWNLOADS = 'https://php2shell.sqzer.com/downloads/';
   1216 const DOWNLOADS = DEFAULT_DOWNLOADS;
   1217 const FETCH_SCRIPT = `curl -fsSLO ${DOWNLOADS}php2shell-server-check.sh\nsha256sum php2shell-server-check.sh`;
   1218 const RHEL_NAMES = {
   1219   rhel: 'RHEL', centos: 'CentOS', rocky: 'Rocky Linux', almalinux: 'AlmaLinux', ol: 'Oracle Linux',
   1220   cloudlinux: 'CloudLinux', eurolinux: 'EuroLinux', amzn: 'Amazon Linux',
   1221 };
   1222 // Rebuilds that republish Red Hat's fixes in advisories of their own.
   1223 const REBUILD_PREFIX = { rocky: 'RLSA', almalinux: 'ALSA', ol: 'ELSA', cloudlinux: 'CLSA' };
   1224 const CVE_GREP = (cves) => cves.map((c) => `-e ${c}`).join(' ');
   1225 
   1226 function buildActions(ctx, r, obs, d, ver, info) {
   1227   const s = actionContext(ctx, r, obs, d, ver, info);
   1228   let steps;
   1229   if (info.noVersion) steps = collectSteps(s, 'noVersion');
   1230   else if (r.verdict === 'patched') steps = patchedSteps(s);
   1231   else if (r.verdict === 'restart') steps = restartSteps(s);
   1232   else steps = fixSteps(s);
   1233   r.actions.push(...steps.filter(Boolean));
   1234 }
   1235 
   1236 function step(s, key, vars = {}, code = null) {
   1237   const text = s.t(key, vars).trim();
   1238   return code ? { text, code } : { text };
   1239 }
   1240 
   1241 // Marks how many steps at the start explain the situation; an interim mitigation that is the only thing to do
   1242 // for now goes straight after them.
   1243 function lead(steps, n) {
   1244   steps.lead = n;
   1245   return steps;
   1246 }
   1247 
   1248 function actionContext(ctx, r, obs, d, ver, info) {
   1249   const a = info.a || {};
   1250   const build = info.build || null;
   1251   const official = obs.configPath === '/usr/local/etc/php';
   1252   const s = {
   1253     ctx,
   1254     t: ctx.t,
   1255     r,
   1256     obs,
   1257     d: d || {},
   1258     ver,
   1259     info,
   1260     a,
   1261     build,
   1262     // When the worst result is another installed build next to the probe's PHP, that build's service matters.
   1263     sapi: build && (obs.phpVersion || !ctx.sapi) ? buildSapi(build) || ctx.sapi : ctx.sapi,
   1264     official,
   1265     container: obs.container === true || official,
   1266     path: info.path,
   1267     fixed: ver ? ctx.upstream.fixed[ver.branch] || null : null,
   1268     pkgs: build ? build.packages : obs.packages.filter((p) => (a.usedKeys || []).includes(buildKey(p))),
   1269     need: info.need || a.need || null,
   1270     // A header, -v output or a bare version: nothing shows where this PHP comes from.
   1271     fromOutside: !obs.sources.includes('selfcheck') && !obs.sources.includes('servercheck'),
   1272   };
   1273   s.names = [...new Set(s.pkgs.map((p) => p.name))];
   1274   // Which PHP an undetermined result is about is open, so no service or file of it is named.
   1275   s.layout = ver && s.path !== 'undetermined' ? phpLayout(s) : null;
   1276   // An end-of-life branch with no package that carries the fix: the move takes time, the mitigation does not.
   1277   s.eolNoFix = Boolean(ver && a.eol && !s.fixed && s.path !== 'remi');
   1278   return s;
   1279 }
   1280 
   1281 // Alpine has no sudo, and its steps assume a root shell.
   1282 function rootShell(s) {
   1283   return s.layout?.init === 'openrc' || s.d.family === 'alpine' || s.obs.os?.id === 'alpine';
   1284 }
   1285 
   1286 function serverScript(s, args = '') {
   1287   return `${asRootIn(s)}sh php2shell-server-check.sh${args}`;
   1288 }
   1289 
   1290 // "sudo " for a command that needs root, except where the steps assume a root shell.
   1291 function asRootIn(s) {
   1292   return rootShell(s) ? '' : 'sudo ';
   1293 }
   1294 
   1295 function inContainer(args = '') {
   1296   return `docker cp php2shell-server-check.sh <container>:/tmp/\ndocker exec -u 0 <container> sh /tmp/php2shell-server-check.sh${args}`;
   1297 }
   1298 
   1299 // "a, b and c" in the language of the result.
   1300 function listText(s, items) {
   1301   if (items.length < 2) return items.join('');
   1302   return `${items.slice(0, -1).join(s.t('list.separator'))}${s.t('list.and')}${items[items.length - 1]}`;
   1303 }
   1304 
   1305 // ── Steps by verdict ────────────────────────────────────────────────
   1306 
   1307 // Vulnerable, Fix pending and Undetermined: what to collect (when open), how to update, restart and confirm,
   1308 // the interim mitigations, and the compromise check.
   1309 function fixSteps(s) {
   1310   const out = [];
   1311   if (s.r.verdict === 'unknown') out.push(...collectSteps(s, s.need || (s.path === 'undetermined' ? 'server' : null)));
   1312   let body = [];
   1313   if (s.path === 'prerelease') {
   1314     body = [s.fixed ? step(s, 'step.prereleaseFixed', { version: s.ver.raw, fixed: s.fixed, branch: s.ver.branch })
   1315       : step(s, 'action.checkReleaseNotes', { version: s.ver.raw }), restartAfterUpdate(s)];
   1316   } else if (['root', 'together', 'unreadable', 'unseen'].includes(s.need)) {
   1317     // The installed package has the fix; only the running processes (or which output is current) are open.
   1318     if (s.need === 'root' || s.need === 'unseen') body = [restartIfUnsure(s)];
   1319   } else if (s.need === 'sapiPackage') {
   1320     // The package of the running SAPI is not fully installed: finishing the update (the step above) comes first.
   1321     body = [restartAfterUpdate(s), recheckStep(s)];
   1322   } else if (s.path !== 'undetermined') {
   1323     body = pathSteps(s);
   1324   }
   1325   const timing = mitigationTiming(s, body);
   1326   body = body.filter(Boolean);
   1327   // With the fixed package installed (the processes, or the end of the output, are what is open), the interim
   1328   // mitigations are not the next step. Nor are they after a downgrade: the running PHP has the fix, and the reload
   1329   // that applies them would load the vulnerable build on disk.
   1330   const fixedOnDisk = ['root', 'together', 'unreadable', 'unseen'].includes(s.need) || (s.need === 'whole' && s.a.verdict === 'patched')
   1331     || Boolean(s.a.downgraded);
   1332   if (!fixedOnDisk) body.splice(timing.at === null ? body.length : Math.min(timing.at, body.length), 0, ...mitigationSteps(s, timing.when));
   1333   out.push(...body, compromiseStep(s));
   1334   return out;
   1335 }
   1336 
   1337 // Where the interim mitigation goes, and how it starts. When nothing can be installed yet (a pending fix, an
   1338 // end-of-life branch, a release with no Red Hat fix), it is the only thing that lowers the risk now, so it follows
   1339 // the steps that explain the situation instead of the update steps that cannot do anything yet.
   1340 function mitigationTiming(s, body) {
   1341   const at = body.lead || 1;
   1342   if (s.r.verdict === 'pending') return { when: 'when.pending', at };
   1343   if (s.eolNoFix) return { when: 'when.eol', at };
   1344   if (body.waiting) return { when: body.waiting, at };
   1345   return { when: 'when.cannotUpdate', at: null };
   1346 }
   1347 
   1348 function pathSteps(s) {
   1349   if (s.container) return containerSteps(s);
   1350   if (s.path === 'deb') return debSteps(s);
   1351   if (s.path === 'sury') return surySteps(s);
   1352   if (s.path === 'rhel') return rhelSteps(s);
   1353   if (s.path === 'remi') return remiSteps(s);
   1354   if (s.path === 'alpine') return alpineSteps(s);
   1355   if (s.path === 'other') return otherSteps(s);
   1356   // A RHEL-family PHP configured under /etc that no rpm package in the server script output accounts for (the
   1357   // php8.4 packages of RHEL 10, for example) is still most likely Red Hat's.
   1358   if (s.d.family === 'rhel' && /^\/etc(\/|$)/.test(s.obs.configPath || '')) return rhelSteps(s);
   1359   return upstreamSteps(s);
   1360 }
   1361 
   1362 // Restart needed: why the old build is still loaded, the restart itself, how to confirm it, and the compromise
   1363 // check (the vulnerable build has been serving requests until now).
   1364 function restartSteps(s) {
   1365   const out = [];
   1366   if (s.container) {
   1367     out.push(step(s, 'step.container.restart', {}, 'docker restart <container>'));
   1368     const run = containerRunLine(s);
   1369     if (run) out.push(step(s, 'step.container.persist', {}, run));
   1370   } else {
   1371     const plan = s.info.genericRestart ? null : restartPlan(s);
   1372     if (plan) out.push(step(s, 'action.restartNow', { extra: plan.extra }, plan.code));
   1373     else if (s.info.genericRestart || s.info.stale?.length) {
   1374       const names = s.info.genericRestart ? s.info.genericRestart.split(', ') : s.info.stale;
   1375       out.push(step(s, 'action.restartProcesses', { names: names.join(', ') }, `ps -o pid,user,cgroup,args -C ${names.join(',')}`));
   1376     } else out.push(step(s, 'action.restartGeneric'));
   1377     out.push(s.obs.sources.includes('servercheck') ? step(s, 'step.restart.confirmServer', {}, serverScript(s)) : step(s, 'step.restart.confirm'));
   1378   }
   1379   out.push(compromiseStep(s));
   1380   return out;
   1381 }
   1382 
   1383 // Patched: other sites and pools, and optional hardening.
   1384 function patchedSteps(s) {
   1385   const out = [step(s, s.container ? 'action.checkOtherContainers' : 'action.checkOtherSites')];
   1386   const ini = s.obs.ini;
   1387   if ((ini && isOn(ini.expose_php)) || s.obs.sources.includes('header')) out.push(exposeStep(s));
   1388   if (!(s.obs.extensions && s.obs.extensions.session === false)) {
   1389     out.push(step(s, ini && isOff(ini['session.upload_progress.enabled']) ? 'step.hardening.uploadOff' : 'step.hardening.uploadOn'));
   1390   }
   1391   return out;
   1392 }
   1393 
   1394 // The server script with --logs and --webroot, which looks for signs of an attack.
   1395 function compromiseStep(s) {
   1396   const args = ' --logs --webroot <webroot>';
   1397   return step(s, 'action.lookForCompromise', {}, s.container ? inContainer(args) : serverScript(s, args));
   1398 }
   1399 
   1400 // ── What to collect when the verdict is open ───────────────────────
   1401 
   1402 function collectSteps(s, need) {
   1403   const hostServer = () => step(s, 'step.collect.server', {}, `${FETCH_SCRIPT}\n${serverScript(s)}`);
   1404   const server = () => (s.container ? step(s, 'step.collect.serverInContainer', {}, `${FETCH_SCRIPT}\n${inContainer()}`) : hostServer());
   1405   const probe = (next) => probeStep(s, next);
   1406   switch (need) {
   1407     case 'noVersion':
   1408       return [probe(true), hostServer()];
   1409     case 'server':
   1410       return s.fromOutside ? [probe(true), server()] : [server()];
   1411     case 'serverOrDistro':
   1412       // The server script output is already here when it is the OS release, not the packages, that is missing.
   1413       if (s.obs.serverData) return [step(s, 'step.collect.distroOnly')];
   1414       if (s.container) return [server()];
   1415       return s.fromOutside ? [probe(true), server(), step(s, 'step.collect.distro')] : [server(), step(s, 'step.collect.distro')];
   1416     case 'root':
   1417     case 'unreadable':
   1418       // As root, a process the script cannot read is usually in a container without CAP_SYS_PTRACE: running it
   1419       // again with sudo gives the same output.
   1420       if (s.obs.root === true) return [step(s, 'step.collect.unreadableAsRoot', {}, `${FETCH_SCRIPT}\n${serverScript(s)}`)];
   1421       return [s.container ? server() : step(s, 'step.collect.root', {}, serverScript(s))];
   1422     case 'unseen': {
   1423       const names = s.a.unseen || [];
   1424       const code = names.some((n) => /-apache2$/.test(n)) ? "ps -o pid,args | grep '[h]ttpd'" : `ps -o pid,lstart,args -C ${names.join(',')}`;
   1425       return [step(s, 'step.collect.unseen', { names: names.join(', ') }, code)];
   1426     }
   1427     case 'sapiPackage': {
   1428       const src = debSrc(s);
   1429       return [step(s, 'step.collect.sapiPackage', { pkg: s.info.sapiPackage || s.a.sapiPackage || `${src}-fpm`, src },
   1430         `dpkg -l '${src}-*' 'libapache2-mod-${src}'\nsudo dpkg --audit\nsudo apt-get update\nsudo apt-get install -f`)];
   1431     }
   1432     case 'vendor': {
   1433       const vendor = s.obs.os?.pretty || s.d.vendor || s.obs.os?.id || 'other';
   1434       return [step(s, 'step.collect.vendor', { vendor }, `rpm -qa 'php*'\nrpm -q --changelog php${s.ver.major} | grep ${CVE_GREP(s.ctx.cves)}`)];
   1435     }
   1436     case 'whole':
   1437       return [step(s, 'step.collect.whole')];
   1438     case 'together':
   1439       return [step(s, 'step.collect.together', {}, serverScript(s))];
   1440     case 'elsewhere':
   1441       return [step(s, 'step.collect.elsewhere', {}, inContainer())];
   1442     case 'hiddenLink':
   1443       return [step(s, 'step.collect.hiddenLink', {}, `${FETCH_SCRIPT}\n${inContainer()}`)];
   1444     case 'advisories': {
   1445       const family = s.d.family === 'ubuntu' ? 'ubuntu' : 'debian';
   1446       return [step(s, 'step.collect.advisories', { family: familyName(family), src: debSrc(s) }, trackerUrls(family, s.ctx.cves))];
   1447     }
   1448     case 'supplier': {
   1449       const pkg = s.names[0] || `${debSrc(s)}-common`;
   1450       return [step(s, 'step.collect.supplier', { family: familyName(s.d.family) }, `apt-cache policy ${pkg}`)];
   1451     }
   1452     default:
   1453       return [];
   1454   }
   1455 }
   1456 
   1457 // The PHP probe through the web server; next adds that the server script follows in the next step.
   1458 function probeStep(s, next) {
   1459   return step(s, 'step.collect.probe', { next: next ? s.t('collect.probeNext') : '' },
   1460     `curl -fsSLO ${DOWNLOADS}php2shell-selfcheck.php\ncurl -s 'https://<site>/<file>.php?k=<TOKEN>'`);
   1461 }
   1462 
   1463 // ── Debian and Ubuntu packages ──────────────────────────────────────
   1464 
   1465 function debSteps(s) {
   1466   const D = debInfo(s);
   1467   if (s.r.verdict === 'pending') {
   1468     return lead([
   1469       step(s, 'step.pending.watch', { family: D.family, release: D.release, src: D.src }, trackerUrls(s.d.family, s.ctx.cves)),
   1470       step(s, 'step.pending.check', { src: D.src }, `sudo apt-get update\napt list --upgradable 2>/dev/null | grep ${D.src}\nsudo apt-get install --only-upgrade ${aptList(s, D.src)}`),
   1471       restartAfterUpdate(s),
   1472     ], 1);
   1473   }
   1474   if (s.need === 'supplier') return supplierSteps(s, D);
   1475   const out = [];
   1476   if (D.pro) out.push(proStep(s, D));
   1477   let fix;
   1478   if (s.a.downgraded) fix = s.t('fix.debDowngraded', { pkg: D.installed || D.src, version: s.ver.raw, fixed: D.fixed ? `${D.src} ${D.fixed}` : `PHP ${s.fixed}` });
   1479   else if (s.r.verdict === 'vulnerable' && D.fixed && D.installed) fix = s.t('fix.deb', { pkg: D.installed, family: D.family, release: D.release, src: D.src, fixed: D.fixed });
   1480   else if (s.r.verdict === 'vulnerable' && D.fixed) fix = s.t('fix.debRunning', { version: s.ver.raw, family: D.family, release: D.release, src: D.src, fixed: D.fixed });
   1481   else fix = s.t('fix.debOpen', { family: D.family, src: D.src });
   1482   out.push(aptUpgradeStep(s, fix, D.src));
   1483   if (s.need === 'advisories') out.push(step(s, 'step.apt.confirmCompare', { family: D.family }, dpkgVersions(s, D.src)));
   1484   else out.push(D.fixed ? step(s, 'step.apt.confirmFixed', { fixed: D.fixed }, dpkgVersions(s, D.src)) : step(s, 'step.apt.confirm', {}, dpkgVersions(s, D.src)));
   1485   out.push(restartAfterUpdate(s));
   1486   // The data has no record to compare with, but the server script reads the changelog of the updated package.
   1487   if (s.need === 'advisories') out.push(step(s, 'step.recheckChangelog', {}, serverScript(s)));
   1488   // The server script output did not name the release, and running it again will not either.
   1489   else if (s.need === 'serverOrDistro' && s.obs.serverData) out.push(step(s, 'step.recheckServerDistro', {}, serverScript(s)));
   1490   else out.push(recheckStep(s));
   1491   return out;
   1492 }
   1493 
   1494 // A build from another repository with the same upstream version: apt never replaces it with the distribution's
   1495 // lower-numbered revision, so the distribution's build is installed explicitly, at its exact version.
   1496 function supplierSteps(s, D) {
   1497   const foreign = D.installedVersion || s.ver.raw;
   1498   return [
   1499     step(s, 'step.apt.switchToDistro', { family: D.family, fixed: D.fixed, foreign, src: D.src }, switchToDistroCode(D.src, foreign, D.fixed)),
   1500     step(s, 'step.apt.confirmExact', { family: D.family, fixed: D.fixed }, `dpkg -l '${D.src}-*' 'libapache2-mod-${D.src}' | grep '^ii'`),
   1501     restartAfterUpdate(s),
   1502     recheckStep(s),
   1503   ];
   1504 }
   1505 
   1506 // Reinstalls every package of this source that is now at the foreign version at the distribution's fixed version.
   1507 function switchToDistroCode(src, foreign, fixed) {
   1508   const lookup = `$(dpkg-query -W -f='\${Package} \${Version}\\n' '${src}-*' 'libapache2-mod-${src}' 2>/dev/null | awk '$2 == "${foreign}" {print $1"=${fixed}"}')`;
   1509   return `sudo apt-get update\nsudo apt-get install --allow-downgrades ${lookup}`;
   1510 }
   1511 
   1512 function proStep(s, D) {
   1513   const cmd = D.release === '14.04' ? 'sudo ua attach <token>' : 'sudo pro attach <token>';
   1514   return step(s, UBUNTU_LEGACY.includes(D.release) ? 'step.ubuntuProLegacy' : 'step.ubuntuPro', { release: D.release }, cmd);
   1515 }
   1516 
   1517 // deb.sury.org follows upstream releases: the same apt commands, with the upstream fixed release as the target.
   1518 function surySteps(s) {
   1519   const src = debSrc(s);
   1520   if (!s.fixed) {
   1521     // deb.sury.org ships backported fixes for end-of-life branches: its latest build may have them, which its
   1522     // changelog shows. The move to a supported branch, or to the distribution's own fixed package, follows.
   1523     const fix = s.t('fix.suryEol', { branch: s.ver.branch, src });
   1524     return lead([aptUpgradeStep(s, fix, src), restartAfterUpdate(s), step(s, 'step.recheckChangelog', {}, serverScript(s)), ...eolSteps(s, { restart: false })], 1);
   1525   }
   1526   const pkg = pickDebPackage(s.obs, s.ver, s.sapi, s.build);
   1527   const fix = s.t('fix.sury', { pkg: pkg ? pkgLabel(pkg) : `PHP ${s.ver.raw}`, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s);
   1528   return [
   1529     aptUpgradeStep(s, fix, src),
   1530     step(s, 'step.apt.confirmUpstream', { fixed: s.fixed, branch: s.ver.branch }, dpkgVersions(s, src)),
   1531     restartAfterUpdate(s),
   1532     recheckStep(s),
   1533   ];
   1534 }
   1535 
   1536 function aptUpgradeStep(s, fix, src) {
   1537   const dpkg = s.names.length > 0;
   1538   return step(s, dpkg ? 'step.apt.upgrade' : 'step.apt.upgradeFind', { fix, names: s.names.join(', '), src },
   1539     `sudo apt-get update\nsudo apt-get install --only-upgrade ${aptList(s, src)}`);
   1540 }
   1541 
   1542 // The installed packages of this PHP, or, when the input does not list them, a lookup of the installed ones
   1543 // (dpkg -l marks them "ii"; dpkg-query -W with a pattern also lists names that are not installed, which apt refuses).
   1544 function aptList(s, src) {
   1545   if (s.names.length) return s.names.join(' ');
   1546   return `$(dpkg -l '${src}-*' 'libapache2-mod-${src}' 2>/dev/null | awk '/^ii/ {print $2}')`;
   1547 }
   1548 
   1549 // The command that shows the installed versions of this PHP's packages.
   1550 function dpkgVersions(s, src) {
   1551   return s.names.length ? `dpkg-query -W ${s.names.join(' ')}` : `dpkg -l '${src}-*' 'libapache2-mod-${src}' | grep '^ii'`;
   1552 }
   1553 
   1554 function debInfo(s) {
   1555   const src = debSrc(s);
   1556   const entry = own(own(own(s.ctx.distro, s.d.family), s.d.release)?.packages, src);
   1557   const fixed = entry ? s.ctx.cves.map((c) => entry.fixed?.[c]).filter(Boolean).sort(compareDebian).pop() || null : null;
   1558   const pkg = pickDebPackage(s.obs, s.ver, s.sapi, s.build);
   1559   const mine = pkg && s.pkgs.includes(pkg);
   1560   return {
   1561     src,
   1562     fixed,
   1563     pro: Boolean(entry?.pro),
   1564     family: familyName(s.d.family),
   1565     release: s.d.release || '',
   1566     installed: mine ? pkgLabel(pkg) : null,
   1567     installedVersion: mine ? pkg.version : null,
   1568   };
   1569 }
   1570 
   1571 function debSrc(s) {
   1572   const p = s.pkgs.find((x) => x.manager === 'dpkg');
   1573   return p ? debSource(p) : srcFor(s.ver);
   1574 }
   1575 
   1576 function familyName(family) {
   1577   return own({ debian: 'Debian', ubuntu: 'Ubuntu' }, family) || family || '';
   1578 }
   1579 
   1580 // The pages that show the fixed version of each release, one per CVE.
   1581 function trackerUrls(family, cves) {
   1582   return cves.map((c) => (family === 'ubuntu' ? `https://ubuntu.com/security/${c}` : `https://security-tracker.debian.org/tracker/${c}`)).join('\n');
   1583 }
   1584 
   1585 // ── RHEL family ─────────────────────────────────────────────────────
   1586 
   1587 function rhelSteps(s) {
   1588   const R = rhelInfo(s);
   1589   const out = [];
   1590   // Releases past the end of their support come first: they decide what the update steps can still bring.
   1591   if (R.vendor === 'amzn') {
   1592     if (R.release === '2') out.push(step(s, 'step.amazon.al2Eol'));
   1593     else if (/^201\d/.test(R.release)) out.push(step(s, 'step.amazon.amiEol', { release: R.release }));
   1594   } else if (R.vendor === 'centos' && R.major && R.major <= 8) {
   1595     // CentOS Linux 6 to 8 and CentOS Stream 8 get no updates from their own repositories; the package commands
   1596     // below help only with a third-party extended support subscription.
   1597     out.push(step(s, 'step.rhel.centosEol', { release: R.release }));
   1598   } else if (R.major && R.major <= 7) {
   1599     out.push(step(s, 'step.rhel.old', { name: R.name, release: R.release }));
   1600   }
   1601   const context = rhelContext(s, R);
   1602   const what = s.t(s.names.length ? 'rhel.what.names' : 'rhel.what.all', { names: s.names.join(', ') });
   1603   const target = s.names.length ? s.names.join(' ') : R.glob;
   1604   const check = R.cves.map((c) => `--cve ${c}`).join(' ');
   1605   const old = R.yum && R.major <= 6;
   1606   const update = old ? `sudo yum update ${target}` : R.yum ? `sudo yum updateinfo list ${check}\nsudo yum update ${target}`
   1607     : `sudo dnf updateinfo list ${check}\nsudo dnf upgrade --refresh ${target}`;
   1608   const upgradeKey = old ? 'step.rhel.update' : R.yum ? 'step.rhel.upgradeYum' : 'step.rhel.upgrade';
   1609   const moduleCheck = R.module && !R.yum;
   1610   if (R.vendor === 'amzn') {
   1611     out.push(step(s, 'step.amazon.watch', { context, release: R.release }, R.cves.map((c) => `https://explore.alas.aws.amazon.com/${c}.html`).join('\n')));
   1612     lead(out, out.length);
   1613     out.push(step(s, upgradeKey, { context: '', what }, update));
   1614   } else if (R.noFix) {
   1615     // Red Hat has published nothing for this branch and release: watch for it, and say that the update steps
   1616     // have nothing to bring yet.
   1617     out.push(step(s, 'step.rhel.watch', { context, release: R.release }, R.cves.map((c) => `https://access.redhat.com/security/cve/${c}`).join('\n')));
   1618     lead(out, out.length);
   1619     if (R.switchTo) {
   1620       out.push(step(s, 'step.rhel.switchStream', { release: R.release, branch: s.ver.branch, streams: listText(s, R.switchTo.streams), target: R.switchTo.branch, build: R.switchTo.build },
   1621         `sudo dnf module switch-to php:${R.switchTo.branch}`));
   1622     }
   1623     out.push(step(s, old ? 'step.rhel.updateNone' : R.switchTo ? 'step.rhel.upgradeNoneStay' : 'step.rhel.upgradeNone', { what, branch: s.ver.branch }, update));
   1624     if (moduleCheck && !R.switchTo) out.push(step(s, 'step.rhel.moduleNoBuild', { branch: s.ver.branch }, 'dnf module list php'));
   1625   } else {
   1626     if (moduleCheck && R.stream) out.push(step(s, 'step.rhel.module', { branch: s.ver.branch, build: R.stream }, 'dnf module list php'));
   1627     out.push(step(s, upgradeKey, { context, what }, update));
   1628     if (!R.release && !R.fixedElsewhere.length) out.push(step(s, 'step.rhel.watchAny', {}, R.cves.map((c) => `https://access.redhat.com/security/cve/${c}`).join('\n')));
   1629   }
   1630   const query = s.names.length ? `rpm -q ${s.names.join(' ')}` : `rpm -qa ${R.glob}`;
   1631   const grep = `rpm -q --changelog ${R.main} | grep ${CVE_GREP(R.grepCves)}`;
   1632   if (R.rebase) {
   1633     // The fix comes as a new upstream release (a Red Hat rebase, or an Amazon Linux update), whose changelog entry
   1634     // names the version, not the CVEs: the installed version shows it.
   1635     out.push(R.fixedNvr ? step(s, 'step.rpm.confirmVersion', { fixed: R.fixedNvr }, query)
   1636       : step(s, 'step.rpm.confirmUpstream', { fixed: s.fixed, branch: s.ver.branch }, query));
   1637   } else if (R.notAffected.length) {
   1638     out.push(step(s, 'step.rhel.confirmSome', { pkg: R.main, cves: R.grepCves.join(', '), notAffected: R.notAffected.join(', '), release: R.release }, `${query}\n${grep}`));
   1639   } else {
   1640     out.push(R.fixedNvr ? step(s, 'step.rhel.confirmFixed', { pkg: R.main, fixed: R.fixedNvr }, `${query}\n${grep}`)
   1641       : step(s, 'step.rhel.confirm', { pkg: R.main }, `${query}\n${grep}`));
   1642   }
   1643   out.push(restartAfterUpdate(s));
   1644   out.push(step(s, R.noFix ? 'step.recheckServerLater' : 'step.recheckServer', {}, serverScript(s)));
   1645   // With no fix to install (or no support left), the interim mitigation is what to do now.
   1646   if (R.eolRelease) out.waiting = 'when.eolRelease';
   1647   else if (R.noFix) out.waiting = 'when.pending';
   1648   if (!out.lead) out.lead = 1;
   1649   return out;
   1650 }
   1651 
   1652 function rhelInfo(s) {
   1653   const { d, ver, ctx } = s;
   1654   const major = rhelMajor(d);
   1655   const release = d.release || '';
   1656   const rh = release ? ctx.distro.redhat?.[release] : null;
   1657   const scl = sclName(s);
   1658   const vendor = d.vendor || 'rhel';
   1659   const branchOf = (x) => x?.advisory && advisoryBranch(x.package) === ver.branch;
   1660   const advisories = vendor === 'amzn' ? [] : (rh?.advisories || []).filter(branchOf);
   1661   const stream = advisories.map((x) => x.package).find((p) => /^php:\d+\.\d+-/.test(p)) || null;
   1662   const nvr = advisories.map((x) => /^php[\d.]*-(?:\d+:)?(\d+\.\d+\.\d+-.+)$/.exec(x.package || '')).find(Boolean);
   1663   // The package name prefix of the fixed build when no package was listed: php8.4 on RHEL 10, php elsewhere.
   1664   const prefix = advisories.map((x) => /^(php[\d.]*)-\d+:/.exec(x.package || '')).find(Boolean)?.[1] || 'php';
   1665   const versions = [ver.raw, ...s.pkgs.map((p) => p.version)].join(' ');
   1666   const main = s.names.find((n) => /-common$/.test(n)) || s.names[0] || (scl ? `${scl}-php-common` : `${prefix}-common`);
   1667   const module = Boolean(stream) || /\.module[+_]/.test(versions);
   1668   const notAffected = release ? ctx.cves.filter((c) => rhelNotAffected(rh, release, c, ver)) : [];
   1669   // Red Hat advisories for this branch on the other releases, for a server whose release is unknown.
   1670   const fixedElsewhere = release ? [] : Object.entries(ctx.distro.redhat || {})
   1671     .map(([rel, x]) => ({ release: rel, ids: [...new Set((x?.advisories || []).filter(branchOf).map((y) => y.advisory))] }))
   1672     .filter((x) => x.ids.length);
   1673   const noFix = Boolean(release) && vendor !== 'amzn' && !advisories.length;
   1674   // Newer module streams of this release that have the fix, when this branch has none.
   1675   let switchTo = null;
   1676   if (noFix && module && !(major && major < 8)) {
   1677     const byBranch = new Map();
   1678     for (const x of rh?.advisories || []) {
   1679       const m = /^php:(\d+\.\d+)-/.exec(x?.package || '');
   1680       if (m && x.advisory && compareBranch(m[1], ver.branch) > 0 && !byBranch.has(m[1])) byBranch.set(m[1], { branch: m[1], advisory: x.advisory, build: x.package });
   1681     }
   1682     const list = [...byBranch.values()].sort((a, b) => compareBranch(a.branch, b.branch));
   1683     if (list.length) switchTo = { branch: list[0].branch, build: list[0].build, streams: list.map((x) => `php:${x.branch} (${x.advisory})`) };
   1684   }
   1685   return {
   1686     major,
   1687     release,
   1688     vendor,
   1689     name: own(RHEL_NAMES, vendor) || 'RHEL',
   1690     yum: Boolean(major && major < 8),
   1691     cves: ctx.cves,
   1692     grepCves: ctx.cves.filter((c) => !notAffected.includes(c)),
   1693     notAffected,
   1694     advisories,
   1695     fixedElsewhere,
   1696     noFix,
   1697     switchTo,
   1698     eolRelease: (vendor === 'centos' && Boolean(major) && major <= 8) || (vendor === 'amzn' && (release === '2' || /^201\d/.test(release))),
   1699     stream,
   1700     fixedNvr: nvr ? nvr[1] : null,
   1701     // The fix is an upstream release at or above the upstream fixed version: a Red Hat rebase (php8.4 8.4.27 on
   1702     // RHEL 10), or Amazon Linux, which ships its PHP fixes as upstream releases.
   1703     rebase: Boolean(ctx.upstream.fixed[ver.branch]) && (vendor === 'amzn'
   1704       || Boolean(nvr && cmpUp(nvr[1].split('-')[0], ctx.upstream.fixed[ver.branch]) >= 0)),
   1705     module,
   1706     glob: scl ? `'${scl}-php*'` : "'php*'",
   1707     main,
   1708     rh,
   1709   };
   1710 }
   1711 
   1712 // What Red Hat (or Amazon) has published for this branch and release, as one or two sentences.
   1713 function rhelContext(s, R) {
   1714   if (R.vendor === 'amzn') return s.t('rhel.context.amazon', { release: R.release || '' }).trim();
   1715   const vars = { branch: s.ver.branch, release: R.release };
   1716   if (!R.release) {
   1717     if (!R.fixedElsewhere.length) return s.t('rhel.context.noReleaseNone', { branch: s.ver.branch });
   1718     const fixes = R.fixedElsewhere.map((x) => s.t('rhel.context.noReleaseItem', { release: x.release, advisories: x.ids.join(', ') }));
   1719     return s.t('rhel.context.noRelease', { branch: s.ver.branch, fixes: listText(s, fixes) });
   1720   }
   1721   let text;
   1722   if (R.advisories.length) {
   1723     const ids = [...new Set(R.advisories.map((x) => `${x.advisory}${x.package ? ` (${x.package})` : ''}`))];
   1724     text = s.t('rhel.context.advisory', { ...vars, advisories: ids.join(', ') });
   1725   } else {
   1726     const states = (R.rh?.states || []).filter((x) => x?.cve && x.state && stateBranch(R.rh, R.release, x.package) === s.ver.branch)
   1727       .map((x) => `${x.cve}: ${own(RHEL_STATES, x.state) ? s.t(RHEL_STATES[x.state]) : x.state}`);
   1728     text = states.length ? s.t('rhel.context.state', { ...vars, states: states.join(', ') }) : s.t('rhel.context.none', vars);
   1729   }
   1730   if (own(REBUILD_PREFIX, R.vendor) && R.advisories.length) text += ` ${s.t('rhel.rebuild', { name: R.name, prefix: REBUILD_PREFIX[R.vendor] })}`;
   1731   return text;
   1732 }
   1733 
   1734 // The PHP branch of a Red Hat package or module stream name: php:8.2-9080020261020145734.9, php-0:8.0.30-8.el9_8.1,
   1735 // php8.4-0:8.4.27-1.el10_2.
   1736 function advisoryBranch(pkg) {
   1737   const m = /^php:(\d+\.\d+)-/.exec(pkg || '') || /^php[\d.]*-(?:\d+:)?(\d+\.\d+)\.\d+-/.exec(pkg || '');
   1738   return m ? m[1] : null;
   1739 }
   1740 
   1741 // The branch a Red Hat state applies to: a module stream (php:8.4/php) names it, the plain php package is the
   1742 // release's own branch.
   1743 function stateBranch(rh, release, pkg) {
   1744   const m = /^php:(\d+\.\d+)(\/|$)/.exec(pkg || '') || /^php(\d+\.\d+)$/.exec(pkg || '');
   1745   if (m) return m[1];
   1746   return pkg === 'php' ? rhelBaseBranch(rh, release) : null;
   1747 }
   1748 
   1749 // Remi's software collection of this PHP (php82), from the build or the configuration path.
   1750 function sclName(s) {
   1751   const key = /^rpm:(php\d+)$/.exec(s.build?.key || '');
   1752   if (key) return key[1];
   1753   const pkg = s.pkgs.map((p) => /^(php\d+)-php(-|$)/.exec(p.name)).find(Boolean);
   1754   if (pkg) return pkg[1];
   1755   const path = /^\/etc\/opt\/remi\/(php\d+)(\/|$)/.exec(s.obs.configPath || '');
   1756   return path ? path[1] : null;
   1757 }
   1758 
   1759 // RHEL major version, for commands: Amazon Linux 2 is EL7-like, 2023 EL9-like, the 201x releases EL6-like.
   1760 function rhelMajor(d) {
   1761   const rel = String(d.release || '');
   1762   if (d.vendor === 'amzn') return rel === '2' ? 7 : rel === '2023' ? 9 : /^201\d/.test(rel) ? 6 : null;
   1763   return Number.parseInt(rel, 10) || null;
   1764 }
   1765 
   1766 // ── Remi, Alpine and other distributions ────────────────────────────
   1767 
   1768 function remiSteps(s) {
   1769   const R = rhelInfo(s);
   1770   const what = s.t(s.names.length ? 'rhel.what.names' : 'rhel.what.all', { names: s.names.join(', ') });
   1771   const target = s.names.length ? s.names.join(' ') : R.glob;
   1772   const upgrade = R.yum ? `sudo yum update ${target}` : `sudo dnf upgrade --refresh ${target}`;
   1773   const pkg = s.pkgs.find((p) => p.manager === 'rpm');
   1774   const label = pkg ? pkgLabel(pkg) : s.obs.configPath || `PHP ${s.ver.raw}`;
   1775   const query = s.names.length ? `rpm -q ${s.names.join(' ')}` : `rpm -qa ${R.glob}`;
   1776   const out = [];
   1777   if (!s.fixed) {
   1778     // Remi's collections of end-of-life branches carry backported fixes under the same version number, so a
   1779     // self-built backport is not offered next to them.
   1780     out.push(step(s, 'step.remi.eol', { pkg: label, branch: s.ver.branch, what }, upgrade));
   1781     out.push(step(s, 'step.rhel.confirm', { pkg: R.main }, `${query}\nrpm -q --changelog ${R.main} | grep ${CVE_GREP(R.cves)}`));
   1782     out.push(restartAfterUpdate(s));
   1783     out.push(step(s, 'step.recheckServer', {}, serverScript(s)));
   1784     out.push(...eolSteps(s, { restart: false, backport: false }));
   1785     return out;
   1786   }
   1787   out.push(step(s, 'step.remi.upgrade', { fix: s.t('fix.remi', { pkg: label, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s), what }, upgrade));
   1788   out.push(step(s, 'step.rpm.confirmUpstream', { fixed: s.fixed, branch: s.ver.branch }, query));
   1789   out.push(restartAfterUpdate(s));
   1790   out.push(recheckStep(s));
   1791   return out;
   1792 }
   1793 
   1794 // Every installed package of this PHP: the PHP-FPM and Apache packages depend on the plain -common package without
   1795 // a version, so updating only some of them would leave a mix of old and new.
   1796 function apkInstalled(name) {
   1797   return `$(apk info | grep '^${name}')`;
   1798 }
   1799 
   1800 function alpineSteps(s) {
   1801   if (!s.fixed) return eolSteps(s);
   1802   const name = alpineName(s);
   1803   return [
   1804     step(s, 'step.apk.upgrade', { fix: s.t('fix.alpine', { names: s.names.length ? s.names.join(', ') : name, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s), fixed: s.fixed, name },
   1805       `apk update\napk upgrade ${apkInstalled(name)}`),
   1806     step(s, 'step.apk.confirm', { fixed: s.fixed, branch: s.ver.branch }, `apk list --installed '${name}*'`),
   1807     restartAfterUpdate(s),
   1808     recheckStep(s),
   1809   ];
   1810 }
   1811 
   1812 // Package update commands for distributions the data does not cover, when the command is certain.
   1813 function otherUpdateCommand(vendor, ver) {
   1814   if (vendor === 'fedora') return "sudo dnf upgrade --refresh 'php*'";
   1815   if (/^(opensuse|sles|sled)/.test(vendor || '')) return `sudo zypper refresh\nsudo zypper update 'php${ver.major}*'`;
   1816   if (vendor === 'arch') return 'sudo pacman -Syu';
   1817   return null;
   1818 }
   1819 
   1820 function otherSteps(s) {
   1821   const vendor = s.d.vendor || s.obs.os?.id || 'other';
   1822   const cmd = otherUpdateCommand(vendor, s.ver);
   1823   const vars = { vendor: s.obs.os?.pretty || vendor, fixed: s.fixed };
   1824   if (!s.fixed) {
   1825     // A distribution that backports fixes into its own PHP packages (SUSE): its update may carry them, and the move
   1826     // to a supported branch is still the real fix. A source build over its packages is not offered.
   1827     if (cmd && BACKPORTING_VENDORS.test(vendor)) {
   1828       return lead([step(s, 'step.other.updateEol', { vendor: vars.vendor, branch: s.ver.branch }, cmd), restartAfterUpdate(s),
   1829         eolSteps(s, { restart: false, backport: false })[0]], 1);
   1830     }
   1831     return eolSteps(s);
   1832   }
   1833   // A backport keeps the version, so running the probe again cannot show it there: the changelog check of the
   1834   // first step does.
   1835   if (BACKPORTING_VENDORS.test(vendor)) return [cmd ? step(s, 'step.other.update', vars, cmd) : step(s, 'step.other.updateNoCode', vars), restartAfterUpdate(s)];
   1836   return [
   1837     cmd ? step(s, 'step.other.update', vars, cmd) : step(s, 'step.other.updateNoCode', vars),
   1838     restartAfterUpdate(s),
   1839     recheckStep(s),
   1840   ];
   1841 }
   1842 
   1843 // ── Upstream builds: self-built, panels, other repositories ─────────
   1844 
   1845 // The binary of this SAPI whose -i output shows the configure options, and whose -v shows its version: the name the
   1846 // distribution gives it where the layout is known (php-fpm8.2 on Debian and Ubuntu, php-fpm83 on Alpine).
   1847 function sapiBinary(s) {
   1848   const L = s.layout;
   1849   if (s.sapi === 'fpm-fcgi' && L?.fpmBinary) return L.fpmBinary;
   1850   if ((s.sapi === 'cgi-fcgi' || s.sapi === 'cgi') && L?.kind === 'deb') return s.ver.major >= 7 ? `php-cgi${s.ver.branch}` : 'php5-cgi';
   1851   return own({ 'fpm-fcgi': 'php-fpm', 'cgi-fcgi': 'php-cgi', cgi: 'php-cgi' }, s.sapi) || 'php';
   1852 }
   1853 
   1854 function upstreamSteps(s) {
   1855   // With only a header, -v output or a version, the probe shows whether this is a distribution package first.
   1856   const out = s.fromOutside ? [probeStep(s, false)] : [];
   1857   if (!s.fixed) {
   1858     out.push(...eolSteps(s));
   1859     return lead(out, out.length > 1 && s.fromOutside ? 2 : 1);
   1860   }
   1861   const fixed = s.fixed;
   1862   out.push(step(s, s.fromOutside ? 'step.upstream.getUnknown' : 'step.upstream.get', { version: s.ver.raw, branch: s.ver.branch, fixed, extra: eolSoon(s) },
   1863     `curl -fsSLO https://www.php.net/distributions/php-${fixed}.tar.xz\nsha256sum php-${fixed}.tar.xz`));
   1864   out.push(step(s, 'step.upstream.build', { fixed },
   1865     `${sapiBinary(s)} -i | grep -i 'configure command'\ntar -xf php-${fixed}.tar.xz\ncd php-${fixed}\n./configure <current-options>\nmake -j"$(nproc)"\n${asRootIn(s)}make install`));
   1866   out.push(restartAfterUpdate(s));
   1867   // The binary that serves the site; the command-line php may be another build, and mod_php has no binary.
   1868   const binary = own({ 'fpm-fcgi': 'php-fpm', 'cgi-fcgi': 'php-cgi', cgi: 'php-cgi' }, s.sapi);
   1869   out.push(binary ? step(s, 'step.upstream.confirmBinary', { fixed, binary }, `${binary} -v`) : step(s, 'step.upstream.confirmProbe', { fixed }));
   1870   return out;
   1871 }
   1872 
   1873 // An end-of-life branch with no fixed release: move to a supported branch, use a package that carries the
   1874 // backport (the closest one on this very server first), or build the php-src backport branch. A build from source is
   1875 // offered only for a PHP that is not a package: make install over a packaged PHP would overwrite the package's files
   1876 // (deb.sury.org builds use the prefix /usr), and its service would keep running the packaged binary.
   1877 function eolSteps(s, { restart = true, backport = s.path === 'upstream' } = {}) {
   1878   const { ver, ctx } = s;
   1879   const [target, alt] = supportedBranches(ctx);
   1880   const out = [step(s, 'step.eol.move', { branch: ver.branch, target, targetFixed: ctx.upstream.fixed[target], alt, altFixed: ctx.upstream.fixed[alt] })];
   1881   const host = hostPackage(s);
   1882   const carriers = eolCarriers(ctx, ver, host ? `${host.family} ${host.release}` : null);
   1883   if (host) {
   1884     const others = carriers.length ? ` ${s.t('eol.others', { branch: ver.branch, carriers: carriers.join('; ') })}` : '';
   1885     const pro = host.pro ? ` ${s.t('eol.hostPro')}` : '';
   1886     const sury = host.sury ? ` ${s.t('eol.hostSury', { src: host.src, fixed: host.fixed })}` : '';
   1887     const vars = { system: `${host.family} ${host.release}`, src: host.src, fixed: host.fixed, family: host.family, pkg: host.pkg, branch: ver.branch, extra: `${pro}${sury}${others}` };
   1888     if (host.foreign) {
   1889       // The installed packages of this very branch come from another repository: the distribution's own build of the
   1890       // same packages replaces them, which apt does only when asked for that exact version.
   1891       out.push(step(s, 'step.eol.hostSwitch', { ...vars, foreign: host.foreign, extra: `${pro}${others}` }, switchToDistroCode(host.src, host.foreign, host.fixed)));
   1892     } else {
   1893       out.push(host.code ? step(s, 'step.eol.host', vars, host.code) : step(s, 'step.eol.hostNoCode', vars));
   1894     }
   1895   } else {
   1896     out.push(carriers.length ? step(s, 'step.eol.distro', { branch: ver.branch, carriers: carriers.join('; ') }) : step(s, 'step.eol.vendor', { branch: ver.branch }));
   1897   }
   1898   const branch = ctx.upstream.backport_branches?.[ver.branch];
   1899   if (branch && backport) {
   1900     out.push(step(s, 'step.eol.backport', { branch: ver.branch, name: branch, version: ver.upstream },
   1901       `${sapiBinary(s)} -i | grep -i 'configure command'\ngit clone --depth 1 --branch ${branch} https://github.com/php/php-src.git\ncd php-src\n./buildconf --force\n./configure <current-options>\nmake -j"$(nproc)"\n${asRootIn(s)}make install`));
   1902   }
   1903   if (restart) out.push(restartAfterUpdate(s));
   1904   return lead(out, 1);
   1905 }
   1906 
   1907 // The fixed PHP package of this server's own Debian or Ubuntu release, on this branch or the nearest newer one.
   1908 function hostPackage(s) {
   1909   const os = s.obs.os || {};
   1910   const family = os.id === 'debian' || os.id === 'ubuntu' ? os.id : null;
   1911   const release = family ? (s.d.family === family && s.d.release) || os.versionId : null;
   1912   const rel = family && release ? own(s.ctx.distro[family], release) : null;
   1913   if (!rel || s.container) return null;
   1914   const list = Object.entries(rel.packages || {}).map(([src, entry]) => {
   1915     if (!s.ctx.cves.every((c) => entry?.fixed?.[c])) return null;
   1916     const fixed = s.ctx.cves.map((c) => entry.fixed[c]).sort(compareDebian).pop();
   1917     const branch = parsePhpVersion(debUpstream(fixed))?.branch;
   1918     return branch && compareBranch(branch, s.ver.branch) >= 0 ? { src, fixed, branch, pro: Boolean(entry.pro) } : null;
   1919   }).filter(Boolean).sort((a, b) => compareBranch(a.branch, b.branch));
   1920   if (!list.length) return null;
   1921   const { src, fixed, pro } = list[0];
   1922   const pkg = own({ apache2handler: `libapache2-mod-${src}`, 'cgi-fcgi': `${src}-cgi`, cgi: `${src}-cgi`, cli: `${src}-cli` }, s.sapi) || `${src}-fpm`;
   1923   // deb.sury.org's packages carry the epoch 1:, so apt prefers them over the distribution's while that repository is
   1924   // on: the step says to turn it off, and apt-cache policy shows which version apt would install.
   1925   const sury = isSuryBuild(s.ver.raw) || s.obs.packages.some((p) => isSuryBuild(p.version));
   1926   // This very branch is the distribution's fixed package, but another repository's build of it is installed.
   1927   const mine = src === srcFor(s.ver) ? pickDebPackage(s.obs, s.ver, s.sapi, s.build) : null;
   1928   const foreign = mine && !sameDeb(mine.version, fixed) ? mine.version : null;
   1929   // Installing the Apache module package enables it right away, next to the PHP module loaded now, so no
   1930   // command is given for it.
   1931   const code = s.sapi === 'apache2handler' ? null : `sudo apt-get update${sury ? `\napt-cache policy ${pkg}` : ''}\nsudo apt-get install ${pkg}`;
   1932   return { family: familyName(family), release, src, fixed, pro, pkg, code, sury, foreign };
   1933 }
   1934 
   1935 // Two supported branches to move to: those with a fixed release and no announced end of life, newest first,
   1936 // leaving out the newest branch when there are more than two (applications usually need the most time for it).
   1937 function supportedBranches(ctx) {
   1938   const eol = ctx.upstream.eol || {};
   1939   let list = Object.keys(ctx.upstream.fixed).filter((b) => !eol[b]).sort(compareBranch).reverse();
   1940   if (list.length > 2) list = list.slice(1);
   1941   return [list[0], list[1] || list[0]];
   1942 }
   1943 
   1944 // Distribution packages in the data that carry both fixes for this branch: "Ubuntu 22.04 (php8.1 8.1.2-1ubuntu2.28)".
   1945 function eolCarriers(ctx, ver, skip = null) {
   1946   const out = [];
   1947   const src = srcFor(ver);
   1948   for (const [family, name] of [['debian', 'Debian'], ['ubuntu', 'Ubuntu']]) {
   1949     for (const [release, rel] of Object.entries(ctx.distro[family] || {})) {
   1950       const entry = rel?.packages?.[src];
   1951       if (!entry || !ctx.cves.every((c) => entry.fixed?.[c]) || `${name} ${release}` === skip) continue;
   1952       const fixed = ctx.cves.map((c) => entry.fixed[c]).sort(compareDebian).pop();
   1953       if (parsePhpVersion(debUpstream(fixed))?.branch !== ver.branch) continue;
   1954       out.push(`${name} ${release} (${src} ${fixed}${entry.pro ? ', Ubuntu Pro' : ''})`);
   1955     }
   1956   }
   1957   for (const [release, rel] of Object.entries(ctx.distro.redhat || {})) {
   1958     const advs = (rel?.advisories || []).filter((x) => x?.advisory && advisoryBranch(x.package) === ver.branch);
   1959     if (!ctx.cves.every((c) => advs.some((x) => x.cve === c))) continue;
   1960     out.push(`RHEL ${release} (${advs[0].package}, ${[...new Set(advs.map((x) => x.advisory))].join(', ')})`);
   1961   }
   1962   return out;
   1963 }
   1964 
   1965 // " PHP 8.2 reaches end of life on 31 December 2026, so plan the move to 8.3 or 8.4 as well." for a branch whose
   1966 // end of life is announced in upstream.json; empty otherwise.
   1967 function eolSoon(s) {
   1968   const date = s.ver && s.fixed ? s.ctx.upstream.eol?.[s.ver.branch] : null;
   1969   if (!date) return '';
   1970   const [high, low] = supportedBranches(s.ctx);
   1971   const past = String(s.ctx.upstream.updated || '') >= date;
   1972   return ` ${s.t(past ? 'branch.eolPast' : 'branch.eolSoon', { branch: s.ver.branch, date: formatDate(date, s.ctx.lang), low, high })}`;
   1973 }
   1974 
   1975 // ── Containers ──────────────────────────────────────────────────────
   1976 
   1977 function containerSteps(s) {
   1978   // The official image keeps PHP outside the package manager, whatever its base system.
   1979   if (s.official && !s.pkgs.length && ['upstream', 'alpine', 'other'].includes(s.path)) return dockerSteps(s);
   1980   const out = [];
   1981   let pro = false;
   1982   if (['deb', 'sury', 'rhel', 'remi', 'alpine'].includes(s.path)) {
   1983     if (s.r.verdict === 'pending') {
   1984       const D = debInfo(s);
   1985       out.push(step(s, 'step.pending.watch', { family: D.family, release: D.release, src: D.src }, trackerUrls(s.d.family, s.ctx.cves)));
   1986       return lead(out, 1);
   1987     }
   1988     const D = s.path === 'deb' ? debInfo(s) : null;
   1989     if (D?.pro && D.release !== '14.04') {
   1990       // The fix is only in Ubuntu Pro, which an image build sees only while it is attached.
   1991       pro = true;
   1992       out.push(step(s, 'step.container.updatePro', { fix: containerFix(s) }, containerProLines(s)));
   1993     } else {
   1994       out.push(step(s, 'step.container.update', { fix: containerFix(s) }, containerRunLine(s)));
   1995     }
   1996   } else if (s.fixed && s.path === 'other') {
   1997     out.push(step(s, 'step.container.other', { vendor: s.obs.os?.pretty || s.d.vendor || s.obs.os?.id || 'other', fixed: s.fixed }));
   1998   } else if (s.fixed) {
   1999     out.push(step(s, 'step.container.upstream', { version: s.ver.raw, branch: s.ver.branch, fixed: s.fixed, extra: eolSoon(s) }));
   2000   } else {
   2001     const [target, alt] = supportedBranches(s.ctx);
   2002     out.push(step(s, 'step.container.eol', { version: s.ver.raw, branch: s.ver.branch, target, targetFixed: s.ctx.upstream.fixed[target], alt, altFixed: s.ctx.upstream.fixed[alt] }));
   2003   }
   2004   out.push(rebuildStep(s, pro));
   2005   out.push(containerConfirm(s));
   2006   return lead(out, 1);
   2007 }
   2008 
   2009 // What fixes the PHP of a container that comes from distribution packages, as one sentence.
   2010 function containerFix(s) {
   2011   if (s.path === 'deb') {
   2012     const D = debInfo(s);
   2013     if (D.fixed && D.installed) return s.t('fix.deb', { pkg: D.installed, family: D.family, release: D.release, src: D.src, fixed: D.fixed });
   2014     if (D.fixed) return s.t('fix.debRunning', { version: s.ver.raw, family: D.family, release: D.release, src: D.src, fixed: D.fixed });
   2015     return s.t('fix.debOpen', { family: D.family, src: D.src });
   2016   }
   2017   if (s.path === 'rhel') return rhelContext(s, rhelInfo(s));
   2018   const pkg = s.pkgs[0];
   2019   const label = pkg ? pkgLabel(pkg) : `PHP ${s.ver.raw}`;
   2020   if (!s.fixed) return s.t('reason.eol', { branch: s.ver.branch });
   2021   if (s.path === 'sury') return s.t('fix.sury', { pkg: label, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s);
   2022   if (s.path === 'remi') return s.t('fix.remi', { pkg: label, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s);
   2023   return s.t('fix.alpine', { names: s.names.join(', ') || alpineName(s), branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s);
   2024 }
   2025 
   2026 // The Dockerfile line that updates this PHP's packages in the image.
   2027 function containerRunLine(s) {
   2028   if (s.path === 'deb' || s.path === 'sury') {
   2029     return s.names.length ? `RUN apt-get update && apt-get install -y --only-upgrade ${s.names.join(' ')}` : 'RUN apt-get update && apt-get -y upgrade';
   2030   }
   2031   if (s.path === 'rhel' || s.path === 'remi') {
   2032     const target = s.names.length ? s.names.join(' ') : rhelInfo(s).glob;
   2033     return (rhelMajor(s.d) || 9) < 8 ? `RUN yum -y update ${target}` : `RUN dnf -y upgrade --refresh ${target}`;
   2034   }
   2035   if (s.path === 'alpine') return `RUN apk upgrade --no-cache ${apkInstalled(alpineName(s))}`;
   2036   return null;
   2037 }
   2038 
   2039 // The Dockerfile lines that attach Ubuntu Pro for the build only (the token comes in as a build secret and is
   2040 // never stored in the image), update the PHP packages, and detach again.
   2041 function containerProLines(s) {
   2042   const names = s.names.length ? s.names.join(' ') : `$(dpkg -l '${debSrc(s)}-*' 'libapache2-mod-${debSrc(s)}' 2>/dev/null | awk '/^ii/ {print $2}')`;
   2043   return [
   2044     'RUN --mount=type=secret,id=pro-attach-config \\',
   2045     '    apt-get update && apt-get install -y --no-install-recommends ubuntu-advantage-tools ca-certificates && \\',
   2046     '    pro attach --attach-config /run/secrets/pro-attach-config && \\',
   2047     `    apt-get update && apt-get install -y --only-upgrade ${names} && \\`,
   2048     '    pro detach --assume-yes',
   2049   ].join('\n');
   2050 }
   2051 
   2052 function containerConfirm(s) {
   2053   let code = null;
   2054   if (s.path === 'deb' || s.path === 'sury') code = `docker exec <container> ${dpkgVersions(s, debSrc(s))}`;
   2055   else if (s.path === 'remi') code = `docker exec <container> rpm -q ${s.names.length ? s.names.join(' ') : rhelInfo(s).main}`;
   2056   else if (s.path === 'alpine') code = `docker exec <container> apk list --installed '${alpineName(s)}*'`;
   2057   else if (s.path === 'rhel') {
   2058     // Red Hat backports the fix without changing the upstream version, so the changelog shows it.
   2059     const R = rhelInfo(s);
   2060     const query = `docker exec <container> rpm -q ${s.names.length ? s.names.join(' ') : R.main}`;
   2061     // A fix that is a new upstream release shows in the version, not in the changelog.
   2062     if (R.rebase) return step(s, 'step.container.confirmFixed', { fixed: R.fixedNvr || s.fixed }, query);
   2063     const grep = `docker exec <container> sh -c "rpm -q --changelog ${R.main} | grep ${CVE_GREP(R.grepCves)}"`;
   2064     const cves = R.grepCves.join(', ');
   2065     return R.fixedNvr ? step(s, 'step.container.confirmChangelogFixed', { pkg: R.main, fixed: R.fixedNvr, cves }, `${query}\n${grep}`)
   2066       : step(s, 'step.container.confirmChangelog', { pkg: R.main, cves }, `${query}\n${grep}`);
   2067   }
   2068   if (!code) {
   2069     const fixed = s.fixed || s.ctx.upstream.fixed[supportedBranches(s.ctx)[0]];
   2070     return step(s, 'step.docker.confirm', { fixed }, "docker exec <container> php -r 'echo PHP_VERSION, PHP_EOL;'");
   2071   }
   2072   const fixed = s.path === 'deb' ? debInfo(s).fixed : s.fixed;
   2073   return fixed ? step(s, 'step.container.confirmFixed', { fixed }, code) : step(s, 'step.container.confirm', {}, code);
   2074 }
   2075 
   2076 // Rebuild with --pull and recreate: plain Docker, and Compose for services that build an image and for those
   2077 // that use one directly (image: in compose.yaml).
   2078 function rebuildStep(s, pro = false) {
   2079   const secret = pro ? ' --secret id=pro-attach-config,src=pro-attach-config.yaml' : '';
   2080   return step(s, 'step.docker.rebuild', {},
   2081     `# Docker\ndocker build --pull${secret} -t <your-image> .\n# Docker Compose\ndocker compose build --pull\ndocker compose pull --ignore-buildable\ndocker compose up -d`);
   2082 }
   2083 
   2084 // The official PHP image: the exact tag of the fixed release in the same variant, rebuild, confirm.
   2085 function dockerSteps(s) {
   2086   const { ver } = s;
   2087   const out = [];
   2088   let expect = s.fixed;
   2089   if (!s.fixed) {
   2090     const [target, alt] = supportedBranches(s.ctx);
   2091     expect = s.ctx.upstream.fixed[target];
   2092     const v = dockerVariant(s, expect);
   2093     out.push(step(s, 'step.docker.eol', { version: ver.raw, branch: ver.branch, targetFixed: expect, altFixed: s.ctx.upstream.fixed[alt], extra: v.extra },
   2094       `FROM php:${expect}${v.sapi ? `-${v.sapi}` : '-<variant>'}${v.os}`));
   2095   } else if (s.sapi === 'frankenphp') {
   2096     out.push(step(s, 'step.docker.frankenphp', { version: ver.raw, branch: ver.branch, fixed: s.fixed }));
   2097   } else {
   2098     const v = dockerVariant(s, s.fixed);
   2099     const extra = `${v.extra}${eolSoon(s)}`;
   2100     if (v.sapi) {
   2101       const suffix = `-${v.sapi}${v.os}`;
   2102       out.push(step(s, 'step.docker.tag', { version: ver.raw, branch: ver.branch, fixed: s.fixed, variant: v.kept ? suffix : `-${v.sapi}`, floating: `${ver.branch}${suffix}`, extra },
   2103         `FROM php:${s.fixed}${suffix}`));
   2104     } else {
   2105       out.push(step(s, 'step.docker.tagNoVariant', { version: ver.raw, branch: ver.branch, fixed: s.fixed, extra }, `FROM php:${s.fixed}-<variant>${v.os}`));
   2106     }
   2107   }
   2108   out.push(rebuildStep(s));
   2109   out.push(step(s, 'step.docker.confirm', { fixed: expect }, "docker exec <container> php -r 'echo PHP_VERSION, PHP_EOL;'"));
   2110   return lead(out, 1);
   2111 }
   2112 
   2113 // The variant part of an official PHP image tag: the SAPI (apache, fpm, cli) and the base system: the Alpine
   2114 // release or Debian codename the image uses now when tags for it are still published, otherwise the nearest newer
   2115 // one, with a sentence that says so.
   2116 function dockerVariant(s, fixed) {
   2117   const sapi = own({ apache2handler: 'apache', 'fpm-fcgi': 'fpm', cli: 'cli' }, s.sapi) || null;
   2118   const os = s.obs.os || {};
   2119   let suffix = '';
   2120   let extra = '';
   2121   let kept = true;
   2122   if (os.id === 'alpine') {
   2123     const rel = /^(\d+\.\d+)/.exec(os.versionId || '')?.[1] || null;
   2124     if (rel && DOCKER_ALPINE.includes(rel)) suffix = `-alpine${rel}`;
   2125     else {
   2126       suffix = '-alpine';
   2127       if (rel) extra = ` ${s.t('docker.alpineFloating', { release: rel, example: `php:${fixed}-${sapi || 'cli'}-alpine${DOCKER_ALPINE[DOCKER_ALPINE.length - 1]}` })}`;
   2128     }
   2129   } else if (os.id === 'debian') {
   2130     const known = own(s.ctx.distro.debian, os.versionId)?.codename;
   2131     const number = Number.parseInt(os.versionId, 10) || own(DEBIAN_RELEASES, os.codename) || own(DEBIAN_RELEASES, known) || null;
   2132     const codename = os.codename || known || Object.keys(DEBIAN_RELEASES).find((k) => DEBIAN_RELEASES[k] === number) || null;
   2133     const hit = DOCKER_DEBIAN.find((x) => x.codename === codename || x.release === number);
   2134     if (hit) suffix = `-${hit.codename}`;
   2135     else if (number && number < DOCKER_DEBIAN[0].release) {
   2136       const to = DOCKER_DEBIAN[0];
   2137       suffix = `-${to.codename}`;
   2138       kept = false;
   2139       extra = ` ${s.t('docker.oldDebian', { from: number, fromName: codename || number, to: to.release, toName: to.codename })}`;
   2140     } else if (codename) extra = ` ${s.t('docker.otherDebian', { codename })}`;
   2141   }
   2142   return { sapi, os: suffix, extra, kept };
   2143 }
   2144 
   2145 // ── Restart ─────────────────────────────────────────────────────────
   2146 
   2147 // The restart step after an update: the exact command when the service is known, a marked placeholder for a
   2148 // self-built PHP-FPM, otherwise the advice alone.
   2149 function restartAfterUpdate(s) {
   2150   if (s.container) return null;
   2151   const plan = restartPlan(s) || restartPlaceholder(s);
   2152   if (plan) return step(s, 'action.restartAfterUpdate', { extra: plan.extra }, plan.code);
   2153   return step(s, 'action.restartAfterUpdateGeneric');
   2154 }
   2155 
   2156 // A restart as a precaution, when the installed package has the fix but the running processes could not be checked.
   2157 function restartIfUnsure(s) {
   2158   const plan = s.container ? null : restartPlan(s);
   2159   return plan ? step(s, 'step.restart.ifUnsure', { extra: plan.extra }, plan.code) : null;
   2160 }
   2161 
   2162 // The services to restart, as commands for the init system of this distribution: those of the processes still
   2163 // using old files, else those of every process the server script saw running this build (Apache with mod_php and
   2164 // PHP-FPM can both run it), else the one of the SAPI. null when the layout is unknown (self-built, panels, others).
   2165 function restartPlan(s) {
   2166   const L = s.layout;
   2167   if (!L || L.kind === 'docker') return null;
   2168   const stale = s.info.stale || [];
   2169   const comms = stale.length ? stale : s.info.running || [];
   2170   if (comms.length) {
   2171     const services = [...new Set(comms.map((c) => serviceForComm(L, c)))];
   2172     if (!services.includes(null)) {
   2173       return { code: services.map((x) => serviceCommand(L, 'restart', x)).join('\n'), extra: services.includes(L.apacheService) ? s.t('restart.modPhp') : '' };
   2174     }
   2175     if (stale.length) return null;
   2176   }
   2177   if (s.sapi === 'apache2handler') return { code: serviceCommand(L, 'restart', L.apacheService), extra: s.t('restart.modPhp') };
   2178   if (s.sapi === 'fpm-fcgi') return { code: serviceCommand(L, 'restart', L.fpmService), extra: '' };
   2179   if (s.sapi === 'cgi-fcgi' || s.sapi === 'cgi') {
   2180     // php-cgi is started by the web server (mod_fcgid) or a FastCGI process manager, not by a service of its own.
   2181     return {
   2182       code: `${serviceCommand(L, 'restart', L.apacheService)}   # Apache mod_fcgid\nps -eo pid,lstart,args | grep -E '[p]hp-cgi|[c]gi-bin/php'`,
   2183       extra: s.t('restart.cgi'),
   2184     };
   2185   }
   2186   return {
   2187     code: `${serviceCommand(L, 'restart', L.apacheService)}   # Apache mod_php\n${serviceCommand(L, 'restart', L.fpmService)}   # PHP-FPM`,
   2188     extra: s.t('restart.choose'),
   2189   };
   2190 }
   2191 
   2192 // The service names a distribution gives Apache and PHP-FPM, where they are fixed. A self-built PHP-FPM has a
   2193 // service of the admin's choosing.
   2194 function serviceNames(s) {
   2195   const vendor = s.d.vendor || s.obs.os?.id || '';
   2196   if (s.d.family === 'debian' || s.d.family === 'ubuntu') return { apache: 'apache2', fpm: null };
   2197   if (s.d.family === 'rhel') return { apache: 'httpd', fpm: null };
   2198   if (s.d.family === 'other' && s.path === 'other') {
   2199     if (vendor === 'fedora' || vendor === 'arch') return { apache: 'httpd', fpm: 'php-fpm' };
   2200     if (/^(opensuse|sles|sled)/.test(vendor)) return { apache: 'apache2', fpm: 'php-fpm' };
   2201   }
   2202   return { apache: null, fpm: null };
   2203 }
   2204 
   2205 // A restart command for a PHP whose layout is not known: the names the distribution fixes, else a marked placeholder.
   2206 function restartPlaceholder(s) {
   2207   const names = serviceNames(s);
   2208   if (s.sapi === 'apache2handler') {
   2209     const apache = names.apache || '<apache-service>';
   2210     const cmd = s.d.family === 'rhel' && (rhelMajor(s.d) || 9) < 7 ? `sudo service ${apache} restart` : `sudo systemctl restart ${apache}`;
   2211     return { code: cmd, extra: s.t('restart.modPhp') };
   2212   }
   2213   if (s.sapi === 'fpm-fcgi') return { code: `sudo systemctl restart ${names.fpm || '<php-fpm-service>'}`, extra: '' };
   2214   return null;
   2215 }
   2216 
   2217 function serviceCommand(L, action, service) {
   2218   if (L.init === 'openrc') return `rc-service ${service} ${action}`;
   2219   if (L.init === 'sysv') return `sudo service ${service} ${action}`;
   2220   return `sudo systemctl ${action} ${service}`;
   2221 }
   2222 
   2223 // The service that starts a process the server script saw, in this distribution's naming.
   2224 function serviceForComm(L, comm) {
   2225   if (L.kind === 'deb') {
   2226     const m = /^php-fpm(\d+\.\d+)$/.exec(comm);
   2227     if (m) return `php${m[1]}-fpm`;
   2228     if (comm === 'php5-fpm') return 'php5-fpm';
   2229     return comm === 'apache2' ? 'apache2' : null;
   2230   }
   2231   if (L.kind === 'rhel' || L.kind === 'scl') {
   2232     if (comm === 'php-fpm') return L.fpmService;
   2233     return comm === 'httpd' ? 'httpd' : null;
   2234   }
   2235   if (L.kind === 'alpine') {
   2236     const m = /^php-fpm(\d+)$/.exec(comm);
   2237     if (m) return `php-fpm${m[1]}`;
   2238     return comm === 'httpd' || comm === 'apache2' ? 'apache2' : null;
   2239   }
   2240   return null;
   2241 }
   2242 
   2243 // Where this PHP keeps its files and what its services are called, for the distributions whose layout is known.
   2244 function phpLayout(s) {
   2245   const { d, ver, path } = s;
   2246   const cp = s.obs.configPath || '';
   2247   const sapiDir = own({ apache2handler: 'apache2', 'fpm-fcgi': 'fpm', 'cgi-fcgi': 'cgi', cgi: 'cgi', cli: 'cli' }, s.sapi);
   2248   if (s.official) return { kind: 'docker' };
   2249   // A packaged build judged on upstream releases (its version already has the fix) keeps its distribution layout.
   2250   const by = (manager) => s.pkgs.some((p) => p.manager === manager);
   2251   const underEtc = /^\/etc(\/|$)/.test(cp);
   2252   if ((d.family === 'debian' || d.family === 'ubuntu') && (path === 'deb' || path === 'sury' || by('dpkg') || /^\/etc\/php5?\//.test(cp))) {
   2253     const base = ver.major >= 7 ? `/etc/php/${ver.branch}` : '/etc/php5';
   2254     // A release that is not known (Debian testing has none) is a current, systemd one.
   2255     const old = (d.family === 'ubuntu' && d.release === '14.04') || (d.family === 'debian' && d.release && Number(d.release) <= 7);
   2256     return {
   2257       kind: 'deb',
   2258       init: old ? 'sysv' : 'systemd',
   2259       confDir: sapiDir ? `${base}/${sapiDir}/conf.d` : null,
   2260       poolDir: `${base}/fpm/pool.d`,
   2261       fpmService: `${srcFor(ver)}-fpm`,
   2262       fpmBinary: ver.major >= 7 ? `php-fpm${ver.branch}` : 'php5-fpm',
   2263       apacheService: 'apache2',
   2264       apacheFile: '/etc/apache2/conf-available/php2shell-mitigation.conf',
   2265       apacheEnable: 'sudo a2enconf php2shell-mitigation',
   2266       apacheTest: 'sudo apachectl configtest',
   2267     };
   2268   }
   2269   const rhelLike = { apacheService: 'httpd', apacheFile: '/etc/httpd/conf.d/php2shell-mitigation.conf', apacheEnable: null, apacheTest: 'sudo apachectl configtest' };
   2270   if (d.family === 'rhel' && (path === 'rhel' || path === 'remi' || by('rpm') || (path === 'upstream' && underEtc))) {
   2271     const init = (rhelMajor(d) || 9) < 7 ? 'sysv' : 'systemd';
   2272     const scl = sclName(s);
   2273     if (scl) {
   2274       return { ...rhelLike, init, kind: 'scl', confDir: `/etc/opt/remi/${scl}/php.d`, poolDir: `/etc/opt/remi/${scl}/php-fpm.d`, fpmService: `${scl}-php-fpm`, fpmBinary: `/opt/remi/${scl}/root/usr/sbin/php-fpm` };
   2275     }
   2276     return { ...rhelLike, init, kind: 'rhel', confDir: '/etc/php.d', poolDir: '/etc/php-fpm.d', fpmService: 'php-fpm', fpmBinary: 'php-fpm' };
   2277   }
   2278   // Fedora packages PHP the way RHEL does.
   2279   if (d.family === 'other' && d.vendor === 'fedora' && path === 'other' && (!cp || underEtc)) {
   2280     return { ...rhelLike, init: 'systemd', kind: 'rhel', confDir: '/etc/php.d', poolDir: '/etc/php-fpm.d', fpmService: 'php-fpm', fpmBinary: 'php-fpm' };
   2281   }
   2282   if (d.family === 'alpine' && (path === 'alpine' || by('apk'))) {
   2283     const name = alpineName(s);
   2284     const n = name.slice(3);
   2285     return {
   2286       kind: 'alpine',
   2287       init: 'openrc',
   2288       confDir: `/etc/${name}/conf.d`,
   2289       poolDir: `/etc/${name}/php-fpm.d`,
   2290       fpmService: `php-fpm${n}`,
   2291       fpmBinary: `php-fpm${n}`,
   2292       apacheService: 'apache2',
   2293       apacheFile: '/etc/apache2/conf.d/php2shell-mitigation.conf',
   2294       apacheEnable: null,
   2295       apacheTest: 'httpd -t',
   2296     };
   2297   }
   2298   return null;
   2299 }
   2300 
   2301 // The Alpine package prefix of this PHP: php83 (from the installed packages, the configuration path or the version).
   2302 function alpineName(s) {
   2303   const pkg = s.pkgs.map((p) => /^(php\d*)/.exec(p.name)).find(Boolean);
   2304   if (pkg) return pkg[1];
   2305   const path = /^\/etc\/(php\d+)$/.exec(s.obs.configPath || '');
   2306   return path ? path[1] : `php${s.ver.major}${s.ver.minor}`;
   2307 }
   2308 
   2309 // ── Confirmation, mitigations, hardening ────────────────────────────
   2310 
   2311 function recheckStep(s) {
   2312   return s.obs.sources.includes('servercheck') ? step(s, 'step.recheckServer', {}, serverScript(s)) : step(s, 'step.recheck');
   2313 }
   2314 
   2315 // The interim mitigations of the official advisory, ready to paste where the place is known: an Apache mod_php
   2316 // configuration file and a PHP-FPM pool file (both when the server script saw both running this PHP), an ini file
   2317 // for CGI, the official image's ini directory, or a self-built PHP's php.ini; otherwise as text. They never replace
   2318 // the fix, and the text says so.
   2319 function mitigationSteps(s, whenKey) {
   2320   const when = s.t(whenKey);
   2321   const ini = s.obs.ini;
   2322   const php85 = s.ver?.branch === '8.5';
   2323   const sessionOff = s.obs.extensions && s.obs.extensions.session === false;
   2324   const inPlace = ini && isOff(ini['session.upload_progress.enabled']) && (!php85 || isOff(ini.fatal_error_backtraces));
   2325   const generic = [step(s, s.r.mitigations.length ? 'action.applyMitigations' : 'action.applyMitigationsAdvisory', { when })];
   2326   if (!s.ver || sessionOff || inPlace) return generic;
   2327   const extra = php85 ? s.t('mitigate.fatalBacktraces') : '';
   2328   const settings = ['session.upload_progress.enabled = 0', ...(php85 ? ['fatal_error_backtraces = 0'] : [])];
   2329   if (s.official && s.container) {
   2330     const file = '"$PHP_INI_DIR/conf.d/zz-php2shell-mitigation.ini"';
   2331     const lines = settings.join('\\n');
   2332     return [step(s, 'step.mitigate.docker', { when, extra }, [
   2333       `docker exec -u 0 <container> sh -c 'printf "${lines}\\n" > ${file}'`,
   2334       'docker restart <container>',
   2335       '# Dockerfile',
   2336       `RUN printf '${lines}\\n' > ${file}`,
   2337     ].join('\n'))];
   2338   }
   2339   const L = s.layout;
   2340   if (L && L.kind !== 'docker' && !s.container) {
   2341     const running = s.info.running || [];
   2342     const apache = s.sapi === 'apache2handler' || running.some((c) => c === 'apache2' || c === 'httpd');
   2343     const fpm = s.sapi === 'fpm-fcgi' || running.some((c) => /^php-fpm/.test(c));
   2344     const out = [];
   2345     if (apache && L.apacheFile) {
   2346       const module = s.ver.major >= 8 ? 'php_module' : s.ver.major === 7 ? 'php7_module' : 'php5_module';
   2347       const lines = [`<IfModule ${module}>`, '    php_admin_flag session.upload_progress.enabled off'];
   2348       if (php85) lines.push('    php_admin_flag fatal_error_backtraces off');
   2349       lines.push('    # php_admin_value memory_limit -1', '</IfModule>');
   2350       const code = [`${asRoot(L, `tee ${L.apacheFile}`)} >/dev/null <<'EOF'`, ...lines, 'EOF'];
   2351       if (L.apacheEnable) code.push(L.apacheEnable);
   2352       code.push(`${L.apacheTest} && ${serviceCommand(L, 'reload', L.apacheService)}`);
   2353       out.push(step(s, 'step.mitigate.apache', { when, extra }, code.join('\n')));
   2354     }
   2355     if (fpm && L.poolDir) {
   2356       const lines = ['[www]', 'php_admin_flag[session.upload_progress.enabled] = off'];
   2357       if (php85) lines.push('php_admin_flag[fatal_error_backtraces] = off');
   2358       lines.push('; php_admin_value[memory_limit] = -1');
   2359       const reload = serviceCommand(L, 'reload', L.fpmService);
   2360       const code = [`${asRoot(L, `tee ${L.poolDir}/zz-php2shell-mitigation.conf`)} >/dev/null <<'EOF'`, ...lines, 'EOF',
   2361         L.fpmBinary ? `${asRoot(L, `${L.fpmBinary} -t`)} && ${reload}` : reload];
   2362       // A reload makes PHP-FPM run its binary from disk again. When its processes still use files from before the
   2363       // last package change, and that change may have been a downgrade, the step says so.
   2364       const staleFpm = (s.info.stale || []).filter((c) => /^php\d*-fpm/.test(c));
   2365       const fpmExtra = staleFpm.length ? `${extra}${s.t('mitigate.staleReload', { names: staleFpm.join(', ') })}` : extra;
   2366       // After the Apache step, the same text again would only repeat it.
   2367       out.push(out.length ? step(s, 'step.mitigate.fpmAlso', { extra: fpmExtra }, code.join('\n')) : step(s, 'step.mitigate.fpm', { when, extra: fpmExtra }, code.join('\n')));
   2368     }
   2369     if (!out.length && (s.sapi === 'cgi-fcgi' || s.sapi === 'cgi') && L.confDir) {
   2370       const plan = restartPlan(s);
   2371       const file = `${L.confDir}/99-php2shell-mitigation.ini`;
   2372       const code = settings.map((x, i) => `echo '${x}' | ${asRoot(L, `tee ${i ? '-a ' : ''}${file}`)} >/dev/null`);
   2373       out.push(plan ? step(s, 'step.mitigate.ini', { when, extra, file }, [...code, plan.code].join('\n'))
   2374         : step(s, 'step.mitigate.iniNoRestart', { when, extra, file }, code.join('\n')));
   2375     }
   2376     if (out.length) return out;
   2377   }
   2378   // A self-built PHP reads php.ini in its own configuration directory.
   2379   if (!s.container && s.path === 'upstream' && s.obs.configPath && !/^\/etc(\/|$)/.test(s.obs.configPath)) {
   2380     const file = `${s.obs.configPath.replace(/\/+$/, '')}/php.ini`;
   2381     const plan = restartPlaceholder(s);
   2382     const code = settings.map((x) => `echo '${x}' | ${asRootIn(s)}tee -a ${file} >/dev/null`);
   2383     return [plan ? step(s, 'step.mitigate.ini', { when, extra, file }, [...code, plan.code].join('\n'))
   2384       : step(s, 'step.mitigate.iniNoRestart', { when, extra, file }, code.join('\n'))];
   2385   }
   2386   return generic;
   2387 }
   2388 
   2389 function asRoot(L, cmd) {
   2390   return L.init === 'openrc' ? cmd : `sudo ${cmd}`;
   2391 }
   2392 
   2393 // expose_php = Off for this PHP: an ini file in the directory this SAPI reads, then a restart of its service.
   2394 function exposeStep(s) {
   2395   if (s.official) return step(s, 'step.hardening.exposeDocker', {}, 'RUN echo \'expose_php = Off\' > "$PHP_INI_DIR/conf.d/zz-hardening.ini"');
   2396   const L = s.layout;
   2397   const plan = L && L.confDir ? restartPlan(s) : null;
   2398   if (plan && !s.container) {
   2399     return step(s, 'step.hardening.expose', {}, `echo 'expose_php = Off' | ${asRoot(L, `tee ${L.confDir}/99-hardening.ini`)}\n${plan.code}`);
   2400   }
   2401   if (s.obs.configPath) return step(s, 'step.hardening.exposePath', { path: s.obs.configPath });
   2402   return step(s, 'step.hardening.exposeManual');
   2403 }
   2404 
   2405 // When patched is true, the interim mitigations meant for unpatched servers are shown for reference only.
   2406 export function evaluateMitigations(ini, extensions, ver, patched = false, lang = 'en') {
   2407   const t = translator(lang);
   2408   const items = [];
   2409   const sessionLoaded = !extensions || extensions.session !== false;
   2410   const add = (name, value, status, note) => items.push({
   2411     name,
   2412     value: value === null || value === undefined ? t('mit.value.notSet') : value === '' ? t('mit.value.emptyOff') : String(value),
   2413     status,
   2414     note: t(note),
   2415   });
   2416 
   2417   const mem = ini.memory_limit;
   2418   if (patched) {
   2419     add('memory_limit', mem, 'info', mem === '-1' ? 'mit.memoryLimit.patchedUnlimited' : 'mit.noChangeNeeded');
   2420   } else {
   2421     add('memory_limit', mem, mem === '-1' ? 'ok' : 'warn', mem === '-1' ? 'mit.memoryLimit.unlimited' : 'mit.memoryLimit.suggest');
   2422   }
   2423 
   2424   const up = ini['session.upload_progress.enabled'];
   2425   if (!sessionLoaded) {
   2426     add('session.upload_progress.enabled', up, 'ok', 'mit.uploadProgress.noSession');
   2427   } else {
   2428     add('session.upload_progress.enabled', up, isOff(up) ? 'ok' : patched ? 'info' : 'warn',
   2429       isOff(up)
   2430         ? 'mit.uploadProgress.off'
   2431         : patched
   2432           ? 'mit.uploadProgress.patchedOn'
   2433           : 'mit.uploadProgress.suggest');
   2434     const auto = ini['session.auto_start'];
   2435     if (!patched || !isOff(auto)) {
   2436       add('session.auto_start', auto, isOff(auto) ? 'ok' : patched ? 'info' : 'warn',
   2437         isOff(auto)
   2438           ? 'mit.autoStart.off'
   2439           : patched
   2440             ? 'mit.noChangeNeeded'
   2441             : 'mit.autoStart.suggest');
   2442     }
   2443   }
   2444 
   2445   const fb = ini.fatal_error_backtraces;
   2446   if (!patched && fb !== null && fb !== undefined && ver && ver.branch === '8.5') {
   2447     add('fatal_error_backtraces', fb, isOff(fb) ? 'ok' : 'warn', isOff(fb) ? 'mit.fatalBacktraces.off' : 'mit.fatalBacktraces.suggest');
   2448   }
   2449 
   2450   const nest = ini.max_input_nesting_level;
   2451   if (nest !== null && nest !== undefined) {
   2452     add('max_input_nesting_level', nest, 'info', 'mit.nestingLevel');
   2453   }
   2454   if (ini.post_max_size !== null && ini.post_max_size !== undefined) {
   2455     add('post_max_size', ini.post_max_size, 'info', 'mit.postMaxSize');
   2456   }
   2457   if (ini.expose_php !== null && ini.expose_php !== undefined) {
   2458     add('expose_php', ini.expose_php, isOff(ini.expose_php) ? 'ok' : 'info', isOff(ini.expose_php) ? 'mit.exposePhp.off' : 'mit.exposePhp.on');
   2459   }
   2460   return items;
   2461 }
   2462 
   2463 export function sapiExposure(sapi, lang = 'en') {
   2464   const t = translator(lang);
   2465   switch (sapi) {
   2466     case 'apache2handler':
   2467       return { level: 'high', text: t('exposure.apache2handler') };
   2468     case 'frankenphp':
   2469       return { level: 'high', text: t('exposure.frankenphp') };
   2470     case 'fpm-fcgi':
   2471       return { level: 'medium', text: t('exposure.fpm') };
   2472     case 'litespeed':
   2473       return { level: 'medium', text: t('exposure.litespeed') };
   2474     case 'cli':
   2475       return { level: 'info', text: t('exposure.cli') };
   2476     default:
   2477       return { level: 'medium', text: t('exposure.other', { sapi }) };
   2478   }
   2479 }
   2480 
   2481 function pickDebPackage(obs, ver, sapi, build) {
   2482   const pool = build ? build.packages : obs.packages;
   2483   const same = pool.filter((p) => p.manager === 'dpkg' && debSource(p) === srcFor(ver));
   2484   const prefer = own({
   2485     apache2handler: /^libapache2-mod-php/,
   2486     'fpm-fcgi': /-fpm$/,
   2487     'cgi-fcgi': /-cgi$/,
   2488     cgi: /-cgi$/,
   2489     cli: /-cli$/,
   2490   }, sapi);
   2491   return (prefer && same.find((p) => prefer.test(p.name))) || same.find((p) => /-common$/.test(p.name)) || same[0] || null;
   2492 }
   2493 
   2494 function debSource(p) {
   2495   return p.source || p.name.replace(/^libapache2-mod-/, '').replace(/-(common|fpm|cli|cgi)$/, '');
   2496 }
   2497 
   2498 // The Debian/Ubuntu source package name of a PHP branch: php8.2, php7.4, php5.
   2499 function srcFor(ver) {
   2500   return ver.major >= 7 ? `php${ver.branch}` : `php${ver.major}`;
   2501 }
   2502 
   2503 // The upstream version of a release's fixed package ("8.3.6" for 8.3.6-0ubuntu0.24.04.13), or null.
   2504 function entryUpstream(entry, cves) {
   2505   const fixed = cves.map((c) => entry?.fixed?.[c]).find(Boolean);
   2506   return fixed ? debUpstream(fixed) : null;
   2507 }
   2508 
   2509 // Releases that may ship exactly this version with the fixes backported, as labels: Debian and Ubuntu releases
   2510 // whose package has this upstream version, and RHEL releases with a fix that keeps the version number (an
   2511 // advisory for exactly this version, or a module stream fix for this branch: a stream keeps the upstream version it
   2512 // started with, which the data does not give, so any version of that branch below the upstream fix may be one).
   2513 function sameUpstreamReleases(ctx, ver) {
   2514   const out = [];
   2515   for (const [family, name] of [['debian', 'Debian'], ['ubuntu', 'Ubuntu']]) {
   2516     for (const [release, rel] of Object.entries(ctx.distro[family] || {})) {
   2517       if (entryUpstream(rel.packages?.[srcFor(ver)], ctx.cves) === ver.upstream) out.push(`${name} ${release}`);
   2518     }
   2519   }
   2520   for (const [release, rel] of Object.entries(ctx.distro.redhat || {})) {
   2521     const keeps = (rel.advisories || []).some((x) => {
   2522       const nevra = /^php[\d.]*-(?:\d+:)?(\d+\.\d+\.\d+)-/.exec(x.package || '');
   2523       if (nevra) return nevra[1] === ver.upstream;
   2524       const stream = /^php:(\d+\.\d+)-/.exec(x.package || '');
   2525       return Boolean(stream) && stream[1] === ver.branch;
   2526     });
   2527     if (keeps) out.push(`RHEL ${release}`);
   2528   }
   2529   return out;
   2530 }
   2531 
   2532 function debAtLeast(version, fixed) {
   2533   return compareDebian(debComparable(version), debComparable(fixed)) >= 0;
   2534 }
   2535 
   2536 function sameDeb(a, b) {
   2537   return compareDebian(debComparable(a), debComparable(b)) === 0;
   2538 }
   2539 
   2540 function maxFixed(states) {
   2541   return states.filter((s) => s.fixed).map((s) => s.fixed).sort(compareDebian).pop();
   2542 }
   2543 
   2544 // Compares two upstream version strings; null when either cannot be read.
   2545 function cmpUp(a, b) {
   2546   const pa = parsePhpVersion(a);
   2547   const pb = parsePhpVersion(b);
   2548   return pa && pb ? comparePhp(pa, pb) : null;
   2549 }
   2550 
   2551 const pkgLabel = (p) => `${p.name} ${p.version}`;
   2552 
   2553 // The distribution to judge against. A choice in the Distribution list is used unless the pasted output
   2554 // contradicts it: then the detected distribution is used, and d.overridden says what was chosen and why.
   2555 export function detectDistro(obs, ver, distro, override, build) {
   2556   distro = distro || {};
   2557   const auto = autoDistro(obs, ver, distro, build);
   2558   if (!override || override === 'auto') return auto;
   2559   const [family, release] = override.split(':');
   2560   const manual = { family, release: release || null, how: 'manual' };
   2561   const by = manualConflict(ver, auto, manual);
   2562   return by ? { ...auto, overridden: { chosen: manual, by } } : manual;
   2563 }
   2564 
   2565 // What in the pasted output contradicts a Distribution list choice: the OS release or a RHEL kernel ('os'), a
   2566 // distribution revision in the version string ('version'), or nothing (null). A distribution the data does not
   2567 // cover ('other') does not contradict a choice, nor does a release that could not be told.
   2568 function manualConflict(ver, auto, manual) {
   2569   const revision = ver?.revision || '';
   2570   if (manual.family === 'upstream') {
   2571     // An upstream build never carries a distribution revision (deb.sury.org builds are judged by upstream anyway).
   2572     return /ubuntu|[~+]deb\d+u\d+|\.el\d/.test(revision) && !isSuryBuild(revision) ? 'version' : null;
   2573   }
   2574   const known = ['debian', 'ubuntu', 'rhel', 'alpine'];
   2575   if (['os', 'derived', 'kernel'].includes(auto.how) && known.includes(auto.family)) {
   2576     if (auto.family !== manual.family) return 'os';
   2577     return auto.release && manual.release && auto.release !== manual.release ? 'os' : null;
   2578   }
   2579   if (auto.how === 'suffix' && auto.family) {
   2580     if (auto.family !== manual.family) return 'version';
   2581     return auto.release && manual.release !== auto.release ? 'version' : null;
   2582   }
   2583   return null;
   2584 }
   2585 
   2586 function overrideNote(t, d, obs, ver) {
   2587   const chosen = plainDistroLabel(t, d.overridden.chosen);
   2588   return d.overridden.by === 'os'
   2589     ? t('note.manualConflictOs', { chosen, detected: distroLabel(t, d, obs) })
   2590     : t('note.manualConflictVersion', { chosen, detected: plainDistroLabel(t, d), version: ver.raw });
   2591 }
   2592 
   2593 function autoDistro(obs, ver, distro, build) {
   2594   const os = obs.os || {};
   2595   const like = `${os.id || ''} ${os.idLike || ''}`;
   2596   // Installed package versions of this branch, which can name the release ("8.2.35-1~deb12u1").
   2597   const pkgVersions = ver
   2598     ? (build ? build.packages : obs.packages || []).filter((p) => p.manager === 'dpkg' && debSource(p) === srcFor(ver)).map((p) => p.version)
   2599     : [];
   2600   if (os.id === 'debian' || os.id === 'ubuntu') {
   2601     const release = os.versionId || codenameToRelease(distro[os.id], os.codename);
   2602     if (release) return { family: os.id, release, how: 'os' };
   2603     const inferred = ver ? inferRelease(os.id, ver, distro, pkgVersions) : null;
   2604     return { family: os.id, release: inferred, how: inferred ? 'suffix' : 'os' };
   2605   }
   2606   if (RHEL_IDS.includes(os.id) || (/\brhel\b/.test(like) && os.id !== 'fedora')) {
   2607     return { family: 'rhel', release: (os.versionId || '').split('.')[0] || null, how: 'os', vendor: os.id };
   2608   }
   2609   if (os.id === 'alpine') return { family: 'alpine', release: os.versionId || null, how: 'os' };
   2610   if (os.id) {
   2611     // Derivatives (Linux Mint, LMDE, Pop!_OS, Raspbian and others) use their parent's PHP packages.
   2612     const parent = /\bubuntu\b/.test(os.idLike || '') ? 'ubuntu' : /\bdebian\b/.test(os.idLike || '') ? 'debian' : null;
   2613     if (parent) {
   2614       const release = codenameToRelease(distro[parent], os.codename) || (ver ? inferRelease(parent, ver, distro, pkgVersions) : null);
   2615       return { family: parent, release, how: 'derived', vendor: os.id };
   2616     }
   2617     return { family: 'other', release: os.versionId || null, how: 'os', vendor: os.id };
   2618   }
   2619 
   2620   // A RHEL-family kernel (5.14.0-427.el9_4.x86_64). Not inside a container: it reports the host's kernel.
   2621   const kernel = /\.el(\d+)/.exec(obs.kernel || '');
   2622   if (kernel && obs.container !== true) return { family: 'rhel', release: kernel[1], how: 'kernel' };
   2623 
   2624   // Without os-release, infer the distribution from the distribution revision in the version string.
   2625   const rev = ver?.revision || '';
   2626   // deb.sury.org builds name a distribution but are not its packages: they never select a release.
   2627   if (isSuryBuild(rev)) return { family: /ubuntu/.test(rev) ? 'ubuntu' : 'debian', release: null, how: 'suffix' };
   2628   if (/[~+]deb\d+u\d+/.test(rev)) return { family: 'debian', release: inferRelease('debian', ver, distro), how: 'suffix' };
   2629   if (/ubuntu/.test(rev)) return { family: 'ubuntu', release: inferRelease('ubuntu', ver, distro), how: 'suffix' };
   2630 
   2631   // A Server header that names a RHEL rebuild ("Apache/2.4.57 (Rocky Linux)") selects the RHEL family, whose
   2632   // version numbers cannot settle the verdict. It is used for nothing else: it may come from a reverse proxy on
   2633   // another system, and it never names the release.
   2634   if (/\((?:Red Hat|Rocky Linux|AlmaLinux|CentOS|Oracle Linux|CloudLinux)[^)]*\)/i.test(obs.serverSoftware || '')) {
   2635     return { family: 'rhel', release: null, how: 'server' };
   2636   }
   2637   return { family: null, release: null, how: 'none' };
   2638 }
   2639 
   2640 // The release named in, or consistent with, the version string; null when it cannot be told.
   2641 // versions: installed package versions of the same branch, used when the version string names no release.
   2642 function inferRelease(family, ver, distro, versions = []) {
   2643   const rev = ver.revision || '';
   2644   if (isSuryBuild(rev)) return null;
   2645   const named = namedRelease(family, rev);
   2646   if (named) return named;
   2647   const fromPackages = [...new Set(versions.filter((v) => !isSuryBuild(v)).map((v) => namedRelease(family, splitDebian(v).revision)).filter(Boolean))];
   2648   if (fromPackages.length === 1) return fromPackages[0];
   2649   if (family === 'debian') return null;
   2650   // Otherwise the only release that ships this source package at the same upstream version.
   2651   const releases = Object.entries(distro.ubuntu || {})
   2652     .filter(([, v]) => {
   2653       const entry = v.packages?.[srcFor(ver)];
   2654       return entry && entryUpstream(entry, Object.keys(entry.fixed || {})) === ver.upstream;
   2655     })
   2656     .map(([k]) => k);
   2657   return releases.length === 1 ? releases[0] : null;
   2658 }
   2659 
   2660 // The release a revision names: "~deb12u1" names Debian 12, "0ubuntu0.24.04.13" names Ubuntu 24.04.
   2661 function namedRelease(family, revision) {
   2662   const m = family === 'debian' ? /[~+]deb(\d+)u\d+/.exec(revision) : /ubuntu\d*\.(\d{2}\.\d{2})/.exec(revision);
   2663   return m ? m[1] : null;
   2664 }
   2665 
   2666 function codenameToRelease(table, codename) {
   2667   if (!table || !codename) return null;
   2668   const hit = Object.entries(table).find(([, v]) => v.codename === codename);
   2669   return hit ? hit[0] : null;
   2670 }
   2671 
   2672 // A distribution as the Distribution list names it: "Ubuntu 22.04", "RHEL family 9", "Upstream build (...)".
   2673 function plainDistroLabel(t, d) {
   2674   if (d.family === 'upstream') return t('distro.upstream');
   2675   if (d.family === 'rhel') return d.release ? t('distro.rhelRelease', { release: d.release }) : t('distro.rhel');
   2676   const names = { debian: 'Debian', ubuntu: 'Ubuntu', alpine: 'Alpine' };
   2677   return `${names[d.family] || d.family}${d.release ? ' ' + d.release : ''}`;
   2678 }
   2679 
   2680 function distroLabel(t, d, obs) {
   2681   const names = { debian: 'Debian', ubuntu: 'Ubuntu', rhel: t('distro.rhel'), alpine: 'Alpine', upstream: t('distro.upstream'), other: obs.os?.id || t('distro.other') };
   2682   if (!d.family) return t('distro.unknown');
   2683   const plain = `${names[d.family] || d.family}${d.release ? ' ' + d.release : ''}`;
   2684   if (d.how === 'derived') return t('distro.derived', { label: obs.os?.pretty || d.vendor, parent: plain });
   2685   const base = obs.os?.pretty && d.how === 'os'
   2686     ? obs.os.pretty
   2687     : d.family === 'rhel' && d.release
   2688       ? t('distro.rhelRelease', { release: d.release })
   2689       : plain;
   2690   if (d.how === 'suffix') return t('distro.inferred', { label: base });
   2691   if (d.how === 'kernel') return t('distro.kernel', { label: base });
   2692   if (d.how === 'server') return t('distro.serverHeader', { label: base });
   2693   if (d.how === 'manual') return t('distro.manual', { label: base });
   2694   return base;
   2695 }
   2696 
   2697 export function sapiLabel(sapi, lang = 'en') {
   2698   const names = {
   2699     apache2handler: 'Apache mod_php',
   2700     'fpm-fcgi': 'PHP-FPM',
   2701     'cgi-fcgi': 'CGI/FastCGI',
   2702     cgi: 'CGI',
   2703     litespeed: 'LiteSpeed (LSAPI)',
   2704     frankenphp: 'FrankenPHP',
   2705     cli: translator(lang)('sapi.cli'),
   2706   };
   2707   return own(names, sapi) ? `${names[sapi]} (${sapi})` : String(sapi ?? '');
   2708 }
   2709 
   2710 function compareBranch(a, b) {
   2711   const [am, an] = a.split('.').map(Number);
   2712   const [bm, bn] = b.split('.').map(Number);
   2713   return am - bm || an - bn;
   2714 }
   2715 
   2716 export function isOff(v) {
   2717   if (v === null || v === undefined) return false;
   2718   return v === '' || v === '0' || /^(off|false|no|none)$/i.test(String(v).trim());
   2719 }
   2720 
   2721 function isOn(v) {
   2722   return v !== null && v !== undefined && !isOff(v);
   2723 }