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 }