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 }