php2shell-scanner.git

php2shell-scanner

~ Check PHP2Shell (CVE-2026-109618, CVE-2026-109619) from inside your server: distribution backports, missed restarts and interim mitigations, verdicts computed in the browser.
git clone https://github.com/debuman/php2shell-scanner.git

site/lib/parse.js (24644B)

      1 // Turns pasted text into an observation.
      2 // Accepted formats: PHP probe output, server script output, response headers (curl -I or curl -IL), php -v output,
      3 // a version string. Text that holds several separate outputs (two probe outputs, two server script outputs, a probe
      4 // output and a header, several X-Powered-By headers, several -v lines) gives one observation per output in
      5 // obs.parts, and judge.js reports the worst of them. The top-level fields then describe the first one.
      6 // Notes for the user come from messages.js, in the language given by options.lang.
      7 // Every value read from the input is untrusted: it is used only as text of the expected type, and never as a key
      8 // that could reach an object's prototype.
      9 
     10 import { translator } from './messages.js';
     11 
     12 const MARKER = /-----(BEGIN|END) PHP2SHELL (SELFCHECK|SERVERCHECK)-----/g;
     13 // The first line of the server script's result block, and the line kinds that follow it.
     14 const SERVER_LINE = /^(?:tool|env|os|pkg|changelog|proc|log|webroot|webuser_proc)\|/m;
     15 const RHEL_IDS = ['rhel', 'centos', 'rocky', 'almalinux', 'ol', 'cloudlinux', 'eurolinux', 'amzn'];
     16 // Longer input is cut, so that a paste of several megabytes cannot keep the page busy.
     17 const MAX_INPUT = 2000000;
     18 // At most this many separate outputs are judged.
     19 const MAX_PARTS = 64;
     20 
     21 export function emptyObservation() {
     22   return {
     23     sources: [],
     24     phpVersion: null,
     25     sapi: null,
     26     os: {},
     27     // { manager, name, version, source, changelog? }. changelog (CVE -> number of changelog lines, null when the
     28     // count is unknown) is the server script's reading of that package at exactly that version.
     29     packages: [],
     30     ini: null,
     31     extensions: null,
     32     container: null,
     33     // True when the probe could not tell whether it runs in a container (open_basedir hides the markers)
     34     containerHidden: false,
     35     configPath: null,
     36     serverSoftware: null,
     37     // php_uname('r') from the probe: the kernel release, e.g. "5.14.0-427.el9_4.x86_64"
     38     kernel: null,
     39     // Whether open_basedir was set where the probe ran (true, false, or null when not reported)
     40     openBasedir: null,
     41     // True when the probe could not read the OS release at all (open_basedir, or no readable release file), so the
     42     // distribution and its packages are hidden from it
     43     osBlocked: false,
     44     // The probe's own dpkg package list ([{ name, version }]), or null when it could not be read or was not reported
     45     probeDpkg: null,
     46     // Whether the server script ran as root (true, false, or null when not reported)
     47     root: null,
     48     // True when the server script's OS, packages and processes describe the system this PHP runs on
     49     serverData: false,
     50     // The probe and the server script list different PHP packages: taken at different times or on different systems
     51     serverConflict: false,
     52     // The server script output ends without its END line, so its later lines are missing
     53     truncated: false,
     54     // The server script output starts without its BEGIN line and without its first lines
     55     headCut: false,
     56     // The input was longer than MAX_INPUT, or held more than MAX_PARTS outputs, so part of it was not read
     57     inputCut: false,
     58     processes: [],
     59     // PHP processes the server script could not check for a missed restart
     60     procUnreadable: 0,
     61     // Compromise indicator counts; null means the source could not be read ("unknown")
     62     findings: {},
     63     notes: [],
     64   };
     65 }
     66 
     67 // Every probe and server script block, in one pass over the text. A block without its END line runs to the next
     68 // marker or to the end. An END line without its BEGIN line (the output was copied from below its first line) ends
     69 // a block that starts where its first data line is.
     70 function findBlocks(text) {
     71   const blocks = [];
     72   let open = null;
     73   let after = 0;
     74   const close = (end, closed, stop) => {
     75     blocks.push({ kind: open.kind, body: text.slice(open.from, end), closed, start: open.start, stop, headless: false });
     76     open = null;
     77     after = stop;
     78   };
     79   for (const m of text.matchAll(MARKER)) {
     80     const kind = m[2] === 'SELFCHECK' ? 'self' : 'server';
     81     const stop = m.index + m[0].length;
     82     if (m[1] === 'BEGIN') {
     83       if (open) close(m.index, false, m.index);
     84       open = { kind, from: stop, start: m.index };
     85     } else if (open) {
     86       // The END line of another kind of output ends the open block too, which then has no END line of its own.
     87       close(m.index, open.kind === kind, stop);
     88     } else {
     89       const region = text.slice(after, m.index);
     90       const first = kind === 'server' ? region.search(SERVER_LINE) : region.indexOf('{');
     91       const start = after + Math.max(first, 0);
     92       blocks.push({ kind, body: text.slice(start, m.index), closed: true, start, stop, headless: true });
     93       after = stop;
     94     }
     95   }
     96   if (open) close(text.length, false, text.length);
     97   return blocks;
     98 }
     99 
    100 // The same output pasted twice counts once.
    101 function unique(blocks) {
    102   const seen = new Set();
    103   return blocks.filter((b) => {
    104     const key = b.body.trim();
    105     if (seen.has(key)) return false;
    106     seen.add(key);
    107     return true;
    108   });
    109 }
    110 
    111 export function parseInput(text, options = {}) {
    112   const t = translator(options.lang || 'en');
    113   try {
    114     return parseText(t, String(text ?? ''));
    115   } catch {
    116     // Never leave the page without a result: an input this parser cannot handle gives no version.
    117     const obs = emptyObservation();
    118     obs.notes.push(t('parse.noVersion'));
    119     return obs;
    120   }
    121 }
    122 
    123 function parseText(t, raw) {
    124   let input = raw.replace(/\r\n?/g, '\n');
    125   if (!input.trim()) return emptyObservation();
    126   let cut = false;
    127   if (input.length > MAX_INPUT) {
    128     input = input.slice(0, MAX_INPUT);
    129     cut = true;
    130   }
    131   const finish = (obs) => {
    132     if (cut) {
    133       obs.inputCut = true;
    134       obs.notes.push(t('parse.inputTooLong'));
    135     }
    136     return obs;
    137   };
    138 
    139   const blocks = findBlocks(input);
    140   if (!blocks.length) {
    141     const trimmed = input.trim();
    142     if (trimmed.startsWith('{')) {
    143       const obs = emptyObservation();
    144       applySelfCheck(t, obs, trimmed);
    145       return finish(obs);
    146     }
    147     const loose = looseObservations(t, input, false);
    148     if (!loose.length) {
    149       const obs = emptyObservation();
    150       obs.notes.push(t('parse.noVersion'));
    151       return finish(obs);
    152     }
    153     return finish(combine(t, loose));
    154   }
    155 
    156   const probes = unique(blocks.filter((b) => b.kind === 'self'));
    157   const servers = unique(blocks.filter((b) => b.kind === 'server'));
    158   const parts = [];
    159   if (servers.length <= 1 && probes.length) {
    160     // One server script output next to one or more probe outputs (several sites or pools on one server).
    161     const sc = servers.length ? serverObservation(t, servers[0]) : null;
    162     let used = false;
    163     let otherSystem = false;
    164     for (const b of probes) {
    165       const obs = emptyObservation();
    166       applySelfCheck(t, obs, b.body);
    167       if (sc) {
    168         const drop = mergeServerCheck(t, obs, sc);
    169         if (!drop) used = true;
    170         else if (drop !== 'parse.serverConflict') otherSystem = true;
    171       }
    172       parts.push(obs);
    173     }
    174     // Server script output from another system (the host of a container, another server) is judged on its own:
    175     // the PHP it shows runs somewhere too. Output of the same system at another moment is not (see judge.js).
    176     if (sc && otherSystem && !used) parts.push(sc);
    177   } else {
    178     // Several server script outputs: which probe belongs to which is unknown, so each output stands alone.
    179     for (const b of probes) {
    180       const obs = emptyObservation();
    181       applySelfCheck(t, obs, b.body);
    182       parts.push(obs);
    183     }
    184     for (const b of servers) parts.push(serverObservation(t, b));
    185   }
    186   // Headers and -v lines pasted next to the blocks. Only lines that start at the left margin count, so the
    187   // server script's own indented text (package lists, log lines) is never read as one.
    188   let rest = '';
    189   let at = 0;
    190   for (const b of blocks) {
    191     rest += `${input.slice(at, Math.max(at, b.start))}\n`;
    192     at = Math.max(at, b.stop);
    193   }
    194   rest += input.slice(at);
    195   parts.push(...looseObservations(t, rest, true));
    196   return finish(combine(t, parts));
    197 }
    198 
    199 function combine(t, parts) {
    200   if (parts.length === 1) return parts[0];
    201   let list = parts;
    202   let tooMany = false;
    203   if (list.length > MAX_PARTS) {
    204     list = list.slice(0, MAX_PARTS);
    205     tooMany = true;
    206   }
    207   const notes = [...new Set(list.flatMap((p) => p.notes))];
    208   if (tooMany) notes.push(t('parse.tooManyOutputs', { n: MAX_PARTS }));
    209   return {
    210     ...list[0],
    211     sources: [...new Set(list.flatMap((p) => p.sources))],
    212     notes,
    213     inputCut: tooMany || list[0].inputCut,
    214     parts: list,
    215   };
    216 }
    217 
    218 function applySelfCheck(t, obs, jsonText) {
    219   let data;
    220   try {
    221     data = JSON.parse(jsonText);
    222   } catch {
    223     obs.notes.push(t('parse.badJson'));
    224     return;
    225   }
    226   if (!data || typeof data !== 'object' || data.tool !== 'php2shell-selfcheck') {
    227     obs.notes.push(t('parse.notSelfcheck'));
    228     return;
    229   }
    230   obs.sources.push('selfcheck');
    231   obs.phpVersion = field(data.php_version) || obs.phpVersion;
    232   obs.sapi = field(data.sapi) || obs.sapi;
    233   const release = isRecord(data.os_release) ? normalizeOs(data.os_release) : {};
    234   obs.os = { ...release, ...obs.os };
    235   obs.kernel = field(data.uname_release);
    236   obs.openBasedir = typeof data.open_basedir === 'boolean' ? data.open_basedir : null;
    237   // os_release is null when no release file could be read (older probes sent an empty list), and under
    238   // open_basedir an empty OS release means it could not be read, not that there is none.
    239   const unread = data.os_release === null || (Array.isArray(data.os_release) && !data.os_release.length);
    240   if (!Object.keys(release).length && (obs.openBasedir || unread)) {
    241     obs.osBlocked = true;
    242     obs.notes.push(t(obs.openBasedir ? 'parse.openBasedir' : 'parse.osReleaseUnread'));
    243   }
    244   if (Array.isArray(data.dpkg_packages)) {
    245     obs.probeDpkg = [];
    246     for (const p of data.dpkg_packages) {
    247       const name = isRecord(p) ? field(p.name) : null;
    248       const version = isRecord(p) ? field(p.version) : null;
    249       if (name && version) {
    250         obs.probeDpkg.push({ name, version });
    251         addPackage(obs, { manager: 'dpkg', name, version, source: cleanSource(p.source) });
    252       }
    253     }
    254   } else if (data.dpkg_packages === null && !obs.osBlocked && /debian|ubuntu/.test(`${release.id || ''} ${release.idLike || ''}`)) {
    255     obs.notes.push(t('parse.dpkgUnreadable'));
    256   }
    257   // An empty list is what PHP sends for an empty array: no settings were read.
    258   obs.ini = cleanIni(data.ini);
    259   obs.extensions = isRecord(data.extensions) ? data.extensions : null;
    260   // open_basedir also hides the container markers, so a "false" there proves nothing.
    261   obs.container = data.container === true ? true : data.container === false && !obs.openBasedir ? false : null;
    262   // The probe reported that it could not tell (null), or open_basedir hid the markers from it.
    263   obs.containerHidden = obs.container === null && Object.prototype.hasOwnProperty.call(data, 'container');
    264   obs.configPath = field(data.config_path);
    265   obs.serverSoftware = field(data.server_software);
    266   if (data.self_deleted === false) {
    267     obs.notes.push(t('parse.probeNotDeleted'));
    268   }
    269 }
    270 
    271 // The server script output on its own: it describes the system it ran on.
    272 function serverObservation(t, block) {
    273   const sc = emptyObservation();
    274   applyServerCheck(t, sc, block.body);
    275   sc.serverData = true;
    276   if (!block.closed) {
    277     sc.truncated = true;
    278     sc.notes.push(t('parse.serverTruncated'));
    279   }
    280   if (block.headless) {
    281     // Copied from below its BEGIN line. Its first line is the tool line: without it, earlier lines are missing too.
    282     const whole = /^tool\|php2shell-server-check\|/m.test(block.body);
    283     sc.headCut = !whole;
    284     sc.notes.push(t(whole ? 'parse.serverNoBegin' : 'parse.serverHeadCut'));
    285   }
    286   return sc;
    287 }
    288 
    289 function applyServerCheck(t, obs, block) {
    290   obs.sources.push('servercheck');
    291   const os = Object.create(null);
    292   const changelog = new Map();
    293   const badStatus = [];
    294   let unreadable = 0;
    295   for (const raw of block.split('\n')) {
    296     const line = raw.trim();
    297     if (!line) continue;
    298     const f = line.split('|');
    299     switch (f[0]) {
    300       case 'os':
    301         if (f[1]) os[f[1]] = f.slice(2).join('|');
    302         break;
    303       case 'env':
    304         if (f[1] === 'root') obs.root = f[2] === '1' ? true : f[2] === '0' ? false : null;
    305         break;
    306       case 'pkg':
    307         if (f[2] && f[3]) {
    308           addPackage(obs, { manager: f[1], name: f[2], version: f[3], source: cleanSource(f[4]) });
    309         }
    310         break;
    311       case 'changelog':
    312         if (f[1] && /^CVE-\d{4}-\d+$/.test(f[2] || '')) {
    313           if (!changelog.has(f[1])) changelog.set(f[1], new Map());
    314           changelog.get(f[1]).set(f[2], count(f[3]));
    315         }
    316         break;
    317       case 'proc':
    318         if (f[1] === 'unreadable') {
    319           unreadable += count(f[2]) ?? 1;
    320           obs.procUnreadable += count(f[2]) ?? 1;
    321         } else if (f[1] && (f[3] === 'ok' || f[3] === 'stale')) {
    322           obs.processes.push({ comm: f[1], count: Number(f[2]) || 0, status: f[3] });
    323         } else if (f[1]) {
    324           // A status other than ok or stale (a line cut off while copying): this process was not checked.
    325           obs.procUnreadable += count(f[2]) || 1;
    326           badStatus.push(f[1]);
    327         }
    328         break;
    329       case 'log':
    330       case 'webroot':
    331         if (f[1] && f[1] !== '__proto__') obs.findings[f[1]] = count(f[2]);
    332         break;
    333       case 'webuser_proc':
    334         obs.findings.webuser_proc = count(f[1]);
    335         break;
    336       default:
    337         break;
    338     }
    339   }
    340   // As root, a process the script cannot read is usually in a container without CAP_SYS_PTRACE: running it again as
    341   // root does not help there.
    342   if (unreadable) obs.notes.push(t(obs.root === true ? 'parse.procUnreadableRoot' : 'parse.procUnreadable', { n: String(unreadable) }));
    343   if (badStatus.length) obs.notes.push(t('parse.procBadStatus', { names: [...new Set(badStatus)].join(', ') }));
    344   // The script reads the changelog of each installed package, so a changelog belongs to the version listed with
    345   // it. A name listed with two versions is ambiguous, and its changelog is not used.
    346   for (const [name, hits] of changelog) {
    347     const pkgs = obs.packages.filter((p) => p.name === name && (p.manager === 'dpkg' || p.manager === 'rpm'));
    348     if (pkgs.length === 1) pkgs[0].changelog = Object.fromEntries(hits);
    349   }
    350   obs.os = normalizeOs(os);
    351 }
    352 
    353 // Adds the server script's observation to the probe's, and returns null, or the message key of the reason it was
    354 // not used. Its compromise indicators are always kept. Its OS, packages, changelogs and processes are used only
    355 // when nothing shows that the two outputs come from different systems or different moments (see unlinked).
    356 function mergeServerCheck(t, obs, sc) {
    357   obs.sources.push('servercheck');
    358   obs.root = sc.root;
    359   obs.findings = { ...obs.findings, ...sc.findings };
    360   const drop = obs.sources.includes('selfcheck') ? unlinked(obs, sc) : null;
    361   if (drop) {
    362     obs.notes.push(t(drop, { os: osLabel(t, sc.os), probe: osLabel(t, obs.os) }));
    363     if (drop === 'parse.serverConflict') obs.serverConflict = true;
    364     if (drop === 'parse.serverUnlinkedHidden') obs.unlinkedHidden = true;
    365     return drop;
    366   }
    367   obs.serverData = true;
    368   obs.truncated = sc.truncated;
    369   obs.headCut = sc.headCut;
    370   // The server script read the OS release and the packages the probe could not see.
    371   if (obs.osBlocked && Object.keys(sc.os || {}).length) {
    372     obs.osBlocked = false;
    373     const hidden = [t('parse.openBasedir'), t('parse.osReleaseUnread')];
    374     obs.notes = obs.notes.filter((n) => !hidden.includes(n));
    375   }
    376   obs.os = { ...obs.os, ...sc.os };
    377   for (const p of sc.packages) addPackage(obs, p);
    378   obs.processes.push(...sc.processes);
    379   obs.procUnreadable += sc.procUnreadable;
    380   for (const n of sc.notes) if (!obs.notes.includes(n)) obs.notes.push(n);
    381   return null;
    382 }
    383 
    384 // Why the server script output does not describe the probe's system, as a message key, or null:
    385 // - both report an OS release, and they differ: another system (the host of a container, or another server);
    386 // - the probe ran in a container, and its own OS release and dpkg package list do not tie the server script
    387 //   output to that container (the script may have run on the host);
    388 // - the probe could not tell whether it runs in a container (open_basedir hides the markers), and its dpkg package
    389 //   list does not tie the two together;
    390 // - the probe read its dpkg package list and it differs from the server script's: different times or systems.
    391 function unlinked(obs, sc) {
    392   const probeOs = obs.os || {};
    393   const serverOs = sc.os || {};
    394   const probeHasOs = Object.keys(probeOs).length > 0;
    395   if (probeHasOs && Object.keys(serverOs).length > 0 && !sameOs(probeOs, serverOs)) {
    396     return obs.container === true ? 'parse.serverOtherSystem' : 'parse.serverOtherOs';
    397   }
    398   const serverDpkg = sc.packages.filter((p) => p.manager === 'dpkg');
    399   const agree = obs.probeDpkg !== null && samePackages(obs.probeDpkg, serverDpkg);
    400   if (obs.container === true && (!probeHasOs || !agree)) return 'parse.serverUnlinked';
    401   if (obs.containerHidden && !agree) return 'parse.serverUnlinkedHidden';
    402   if (obs.probeDpkg !== null && !agree) return 'parse.serverConflict';
    403   return null;
    404 }
    405 
    406 // Whether two OS releases are the same. Without os-release the server script writes "rhel" and the major version for
    407 // every /etc/redhat-release, where the probe names the vendor from the file's text (CentOS, CloudLinux, Oracle Linux).
    408 function sameOs(a, b) {
    409   if (a.id === b.id) return (a.versionId || '') === (b.versionId || '');
    410   const legacy = (o) => o.id === 'rhel' && !o.idLike && /^\d+$/.test(o.versionId || '');
    411   const rhelish = (o) => RHEL_IDS.includes(o.id) || /\brhel\b/.test(o.idLike || '');
    412   if ((legacy(a) && rhelish(b)) || (legacy(b) && rhelish(a))) {
    413     const major = (o) => String(o.versionId || '').split('.')[0];
    414     return major(a) !== '' && major(a) === major(b);
    415   }
    416   return false;
    417 }
    418 
    419 // The probe lists at most 40 packages; a full list only has to be part of the server script's.
    420 const PROBE_DPKG_MAX = 40;
    421 
    422 function samePackages(probeList, serverList) {
    423   const key = (p) => `${p.name} ${p.version}`;
    424   const sa = new Set(probeList.map(key));
    425   const sb = new Set(serverList.map(key));
    426   return (sa.size === sb.size || probeList.length >= PROBE_DPKG_MAX) && [...sa].every((k) => sb.has(k));
    427 }
    428 
    429 function osLabel(t, os) {
    430   return os?.pretty || [os?.id, os?.versionId].filter(Boolean).join(' ') || t('distro.unknown');
    431 }
    432 
    433 function addPackage(obs, p) {
    434   const same = obs.packages.find((q) => q.manager === p.manager && q.name === p.name && q.version === p.version);
    435   if (!same) obs.packages.push({ ...p });
    436   else if (p.changelog && !same.changelog) same.changelog = p.changelog;
    437 }
    438 
    439 const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
    440 
    441 // A string field of the probe output, trimmed, or null when it is missing, empty or not a string.
    442 function field(value) {
    443   return typeof value === 'string' && value.trim() ? value.trim() : null;
    444 }
    445 
    446 // A text field of an OS release: a string, or a number written as text; anything else (an object, a list) is not used.
    447 function osField(value) {
    448   if (typeof value === 'number' && Number.isFinite(value)) return String(value);
    449   return field(value);
    450 }
    451 
    452 // The probe's ini settings: text values only. A value of another type (an object, a list) is left out, as if the
    453 // setting had not been read.
    454 function cleanIni(ini) {
    455   if (!isRecord(ini)) return null;
    456   const out = {};
    457   for (const [name, value] of Object.entries(ini)) {
    458     if (name === '__proto__') continue;
    459     if (value === null || typeof value === 'string') out[name] = value;
    460     else if (typeof value === 'number' || typeof value === 'boolean') out[name] = String(value);
    461   }
    462   return out;
    463 }
    464 
    465 // A count from the server script: a non-negative integer, or null for "unknown" (the source could not be read).
    466 function count(value) {
    467   const s = String(value ?? '').trim();
    468   return /^\d{1,9}$/.test(s) ? Number(s) : null;
    469 }
    470 
    471 // A PHP version, as it appears in headers, -v output and version strings. The lengths are bounded, so that a long
    472 // run of digits cannot make the search slow.
    473 const VERSION = '(\\d{1,2}\\.\\d{1,3}\\.\\d{1,5}[^\\s,;()]{0,100})';
    474 
    475 // X-Powered-By and Server headers, -v output lines and, when nothing else is found and strict is false, version
    476 // strings. One observation per distinct PHP version. strict: only lines that start at the left margin count.
    477 function looseObservations(t, text, strict) {
    478   const out = [];
    479   const lead = strict ? '' : '[ \\t]*';
    480   // curl -IL prints one header block per redirect hop, each starting with a status line.
    481   const headers = new Map();
    482   for (const response of text.split(/^(?=[ \t]*HTTP\/\d)/m)) {
    483     const serverLine = new RegExp(`^${lead}Server:[ \\t]*([^\\n]*)$`, 'im').exec(response);
    484     const server = serverLine && serverLine[1].trim() ? serverLine[1].trim().slice(0, 200) : null;
    485     // A PHP version anywhere in an X-Powered-By or Server header line: a proxy may merge several values into one
    486     // line ("PleskLin, PHP/8.2.20", "PHP/8.4.27, PHP/8.2.20"), and some servers name PHP in the Server header.
    487     for (const line of response.matchAll(new RegExp(`^${lead}(?:X-Powered-By|Server):[ \\t]*([^\\n]*)$`, 'gim'))) {
    488       for (const m of line[1].matchAll(new RegExp(`\\bPHP\\/${VERSION}`, 'gi'))) {
    489         if (!headers.has(m[1]) || (!headers.get(m[1]) && server)) headers.set(m[1], server);
    490       }
    491     }
    492   }
    493   for (const [version, server] of headers) {
    494     const obs = emptyObservation();
    495     obs.sources.push('header');
    496     obs.phpVersion = version;
    497     obs.serverSoftware = server;
    498     obs.notes.push(t('parse.headerOnly'));
    499     out.push(obs);
    500   }
    501   // php -v, php-fpm8.2 -v, php-cgi -v: "PHP 8.2.20 (fpm-fcgi) (built: ...)"
    502   const seen = new Set();
    503   const cli = `^${lead}PHP[ \\t]+(\\d{1,2}\\.\\d{1,3}\\.\\d{1,5}\\S{0,100})[ \\t]+\\(([A-Za-z0-9-]{1,40})\\)`;
    504   for (const m of text.matchAll(new RegExp(cli, 'gim'))) {
    505     const sapi = m[2].toLowerCase();
    506     if (seen.has(`${m[1]} ${sapi}`)) continue;
    507     seen.add(`${m[1]} ${sapi}`);
    508     const obs = emptyObservation();
    509     obs.sources.push('cli');
    510     obs.phpVersion = m[1];
    511     obs.sapi = sapi;
    512     if (sapi === 'cli') obs.notes.push(t('parse.cliVersion'));
    513     out.push(obs);
    514   }
    515   if (!strict && !out.length) {
    516     // Version strings: at the start of the input, on a line of their own, or after "PHP" ("PHP/8.2.20", "PHP 8.2.20").
    517     const versions = new Set();
    518     const trimmed = text.trim();
    519     const first = new RegExp(`^${VERSION}`).exec(trimmed);
    520     if (first) versions.add(first[1]);
    521     for (const m of trimmed.matchAll(new RegExp(`^[ \\t]*${VERSION}[ \\t]*$|\\bPHP\\/?[ \\t]*${VERSION}`, 'gim'))) {
    522       versions.add(m[1] || m[2]);
    523     }
    524     for (const version of versions) {
    525       const obs = emptyObservation();
    526       obs.sources.push('version');
    527       obs.phpVersion = version;
    528       out.push(obs);
    529     }
    530   }
    531   return out;
    532 }
    533 
    534 function normalizeOs(o) {
    535   const out = {};
    536   const id = osField(o.ID);
    537   const idLike = osField(o.ID_LIKE);
    538   const versionId = osField(o.VERSION_ID);
    539   const codename = osField(o.VERSION_CODENAME);
    540   const pretty = osField(o.PRETTY_NAME);
    541   if (id) out.id = id.toLowerCase();
    542   if (idLike) out.idLike = idLike.toLowerCase();
    543   if (versionId) out.versionId = versionId;
    544   if (codename) out.codename = codename.toLowerCase();
    545   if (pretty) out.pretty = pretty;
    546   // The probe names the legacy keys after the file: LEGACY_REDHAT-RELEASE, LEGACY_DEBIAN_VERSION.
    547   const redhat = osField(o['LEGACY_REDHAT-RELEASE']) || osField(o.LEGACY_REDHAT_RELEASE);
    548   if (!out.pretty && redhat) out.pretty = redhat;
    549   if (!out.id && redhat) {
    550     // Fedora writes the same file but is not part of the RHEL family.
    551     out.id = /centos/i.test(redhat) ? 'centos' : /rocky/i.test(redhat) ? 'rocky' : /alma/i.test(redhat) ? 'almalinux'
    552       : /cloudlinux/i.test(redhat) ? 'cloudlinux' : /oracle/i.test(redhat) ? 'ol' : /amazon/i.test(redhat) ? 'amzn'
    553         : /fedora/i.test(redhat) ? 'fedora' : 'rhel';
    554     const m = /release (\d+)/.exec(redhat);
    555     if (m) out.versionId = m[1];
    556   }
    557   const debian = osField(o['LEGACY_DEBIAN-VERSION']) || osField(o.LEGACY_DEBIAN_VERSION);
    558   if (!out.id && debian) {
    559     out.id = 'debian';
    560     const m = /^(\d+)/.exec(debian);
    561     if (m) out.versionId = m[1];
    562   }
    563   return out;
    564 }
    565 
    566 // The dpkg Source field sometimes carries a version, as in "php8.1 (8.1.2-1ubuntu2.28)".
    567 function cleanSource(source) {
    568   return (typeof source === 'string' ? source : '').replace(/\s*\(.*\)\s*$/, '').trim();
    569 }