php-radar 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,571 @@
1
+ """Reads the PHP installed on a machine you control, this one or one reached over SSH.
2
+
3
+ inspect_local and inspect_ssh run php -v, php -i, and the package manager on the machine and
4
+ return one HOST Evidence. HOST outranks every other source. Each command is a fixed argument
5
+ list that goes to subprocess with no shell. The program is found by walking the absolute entries
6
+ of PATH, because Windows would run a php.exe or ssh.exe planted in the current directory first.
7
+ Over SSH the login shell on the far side still splits the command line. For that reason no value
8
+ read from a machine is ever put into a later command. A target also has to look like a host name
9
+ or address before ssh sees it.
10
+
11
+ Output from a machine is data. This module keeps the version, the SAPI, four ini settings, the OS
12
+ name, and the versions of PHP packages, each after a check on its length and characters. It drops
13
+ the rest of php -i, because the Environment section holds the environment of the process and any
14
+ credentials that were in it.
15
+
16
+ It sets the distro signal only when an installed package confirms that the running PHP is a distro
17
+ build, since a backport makes the upstream number lie. PHP built from source gets no distro signal
18
+ and grades on its number.
19
+
20
+ php_present is "false", with an empty note, only on a positive read: the machine answered, has no
21
+ php command, and lists no package with php in its name. Any read that fails leaves php_present
22
+ unset and says why in the note, so the grader returns INDETERMINATE and never NOT_PHP.
23
+
24
+ Limits. The ini values annotate and move no verdict. They come from the php that answered, and the
25
+ CLI often reads a different php.ini from the web SAPI. A machine with several PHP versions
26
+ installed reports the one that php runs. A package says what is installed and nothing about a
27
+ process that started before it was upgraded. A removed package whose configuration files remain
28
+ still lists under dpkg-query -W. That holds NOT_PHP back. So does phpmyadmin: the test for a PHP
29
+ package is loose on purpose, because packagers name PHP in many ways, while matching a build to
30
+ the running version is exact.
31
+ """
32
+ import os
33
+ import socket
34
+ import string
35
+ import subprocess
36
+ from typing import Callable, NamedTuple, Optional
37
+
38
+ from php_radar.model import Evidence, Sapi, Source
39
+ from php_radar.versions import PhpVersion
40
+ # The grader decides what a distro revision is, and the network source tells a name from an
41
+ # address. Using theirs keeps a host read and the other reads of one machine in agreement.
42
+ from php_radar.knowledge import _is_revision
43
+ from php_radar.sources.network import _identity
44
+
45
+ # Seconds for one command. An ssh connect has a shorter limit of its own.
46
+ _TIMEOUT = 30.0
47
+
48
+ # The exit status of a POSIX shell for a command it cannot find, and the one ssh gives when the
49
+ # trouble is its own: the connection, the login, or the configuration.
50
+ _NOT_FOUND = 127
51
+ _SSH_FAILED = 255
52
+
53
+ # BatchMode keeps ssh from asking a question nobody is there to answer. The agent, X11, and any
54
+ # forwarding the user's config sets stay off, because the machine on the far side may be the one
55
+ # under suspicion. Keys, ports, jump hosts, and the host key check are left to the user's config.
56
+ _SSH = ["ssh", "-a", "-x", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
57
+ "-o", "ClearAllForwardings=yes"]
58
+
59
+ _V = ("php", "-v")
60
+ _I = ("php", "-i")
61
+ _OS = ("cat", "/etc/os-release")
62
+ _DPKG = ("dpkg-query", "-W")
63
+ _RPM = ("rpm", "-qa")
64
+
65
+ _ALNUM = string.ascii_letters + string.digits
66
+ _NAME = frozenset(_ALNUM + "-._") # a host name, a login name, an os-release ID or VERSION_ID
67
+ _HOST = _NAME | frozenset(":") # an ssh host, which may be an IPv6 address
68
+ _HOST_START = frozenset(_ALNUM + ":")
69
+ _MAX_HOST = 253
70
+ _MAX_USER = 64
71
+ _MAX_ID = 32
72
+
73
+ _VERSION_CHARS = frozenset(_ALNUM + ".+~_-")
74
+ # No real version string is longer than this. A build string cut short would hand the grader a
75
+ # build that no machine printed, so a longer one is dropped.
76
+ _MAX_VERSION = 64
77
+ # The grader reads no package build longer than this.
78
+ _MAX_BUILD = 128
79
+ _MAX_NAME = 64
80
+
81
+ # The four settings take values like 128M, -1, On, and no value.
82
+ _VALUE_CHARS = frozenset(_ALNUM + " -")
83
+ _MAX_VALUE = 32
84
+
85
+ _DEB_NAME = frozenset(string.ascii_lowercase + string.digits + "+-.")
86
+ _DEB_VERSION = frozenset(_ALNUM + ".+-~")
87
+ _RPM_CHARS = frozenset(_ALNUM + "._+~")
88
+ _RPM_NAME = _RPM_CHARS | frozenset("-")
89
+ _ARCHES = frozenset({"x86_64", "i386", "i486", "i586", "i686", "aarch64", "armv7hl", "ppc64",
90
+ "ppc64le", "s390x", "noarch"})
91
+
92
+ # Four ini settings recorded as context for a triager. They annotate a finding and never clear
93
+ # one. No ini setting fixes the memory-corruption path of CVE-2026-109618, which a large
94
+ # multipart request reaches on its own, so session.upload_progress.enabled bears only on
95
+ # CVE-2026-109619, and memory_limit reads backwards: a finite limit is the condition the trigger
96
+ # needs and -1 is protective.
97
+ _INI = frozenset({"memory_limit", "post_max_size", "session.upload_progress.enabled",
98
+ "fatal_error_backtraces"})
99
+ _WANTED = _INI | {"PHP Version", "Server API"}
100
+
101
+ # Keyed by the lower-case name php -v puts in parentheses and by the one php -i prints.
102
+ _SAPIS = {
103
+ "cli": Sapi.CLI, "command line interface": Sapi.CLI,
104
+ "fpm-fcgi": Sapi.FPM, "fpm/fastcgi": Sapi.FPM,
105
+ "cgi-fcgi": Sapi.CGI, "cgi": Sapi.CGI, "cgi/fastcgi": Sapi.CGI,
106
+ "apache2handler": Sapi.MOD_PHP, "apache 2.0 handler": Sapi.MOD_PHP,
107
+ "apache2filter": Sapi.MOD_PHP, "apache 2.0 filter": Sapi.MOD_PHP,
108
+ }
109
+
110
+ # The os-release IDs that the matrix data file keys as suse. Every other ID is its own key.
111
+ _ALIASES = {"sles": "suse", "sled": "suse", "sles_sap": "suse", "opensuse": "suse",
112
+ "opensuse-leap": "suse", "opensuse-tumbleweed": "suse"}
113
+ _DEB_DISTROS = frozenset({"debian", "ubuntu"})
114
+ _RPM_DISTROS = frozenset({"suse", "rhel", "centos", "fedora", "rocky", "almalinux", "amzn", "ol"})
115
+
116
+ Run = Callable[[list], subprocess.CompletedProcess]
117
+
118
+
119
+ def _on_path(name: str) -> Optional[str]:
120
+ """The full path of the program called name on PATH, or None.
121
+
122
+ PATH is walked here and nothing else is searched. CreateProcess on Windows tries the current
123
+ directory before PATH, and an empty or relative PATH entry means the current directory on
124
+ POSIX. A php or ssh planted where the tool runs would then start as the operator. Only an
125
+ absolute entry counts, and that includes one that names the current directory, because the
126
+ operator put it on PATH. An empty entry is not absolute, so it is skipped with the relative
127
+ ones. Windows allows an entry in double quotes. On Windows the program is name.exe, because a
128
+ script would run through cmd.exe and its argument rules.
129
+ """
130
+ suffix = ".exe" if os.name == "nt" and not name.lower().endswith(".exe") else ""
131
+ for entry in os.environ.get("PATH", "").split(os.pathsep):
132
+ entry = entry.strip('"')
133
+ if not os.path.isabs(entry):
134
+ continue
135
+ path = os.path.join(entry, name + suffix)
136
+ if os.path.isfile(path) and os.access(path, os.X_OK):
137
+ return path
138
+ return None
139
+
140
+
141
+ def _program(name: str) -> str:
142
+ """The program to start for name: an absolute path as given, else its place on PATH."""
143
+ if os.path.isabs(name):
144
+ return name
145
+ if os.path.basename(name) != name:
146
+ # A relative path is the current directory again, under another spelling.
147
+ raise ValueError("a program is a bare name or an absolute path")
148
+ path = _on_path(name)
149
+ if path is None:
150
+ raise FileNotFoundError(2, "not found on PATH", name)
151
+ return path
152
+
153
+
154
+ def subprocess_run(args: list, timeout: float = _TIMEOUT) -> subprocess.CompletedProcess:
155
+ """Run one command and return what subprocess.run returns.
156
+
157
+ This is the one place a process starts. It takes a list of strings and nothing else, because
158
+ Windows splits a string into words even with the shell off. It finds the program itself, on
159
+ the absolute entries of PATH, and starts it by its full path. A program that is not on PATH
160
+ raises FileNotFoundError. Nothing waits on a terminal: stdin is closed and stderr is dropped.
161
+ Output is read as UTF-8, and a byte that is not UTF-8 becomes a replacement character.
162
+ """
163
+ if not isinstance(args, list) or not args or not all(isinstance(a, str) for a in args):
164
+ raise TypeError("a command is a list of strings")
165
+ return subprocess.run(
166
+ [_program(args[0])] + args[1:], shell=False, stdin=subprocess.DEVNULL,
167
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=timeout, check=False,
168
+ encoding="utf-8", errors="replace")
169
+
170
+
171
+ class _Got(NamedTuple):
172
+ out: Optional[str] = None # stdout, when the command ran and exited 0
173
+ missing: bool = False # the command was not found
174
+ why: str = "" # what went wrong, in words fit for a note
175
+ code: Optional[int] = None # the exit status, when there was one
176
+
177
+
178
+ def _get(run: Run, argv: list) -> _Got:
179
+ try:
180
+ done = run(argv)
181
+ except FileNotFoundError:
182
+ return _Got(missing=True, why="not found")
183
+ except subprocess.TimeoutExpired:
184
+ return _Got(why="timed out")
185
+ except OSError as exc:
186
+ return _Got(why=f"could not start ({type(exc).__name__})")
187
+ code = done.returncode
188
+ if code == 0:
189
+ return _Got(out=done.stdout or "", code=code)
190
+ if code == _NOT_FOUND:
191
+ return _Got(missing=True, why="not found", code=code)
192
+ return _Got(why=f"exit {code}", code=code)
193
+
194
+
195
+ class _Machine:
196
+ """One machine being read: how to reach it, and what its commands have printed so far."""
197
+
198
+ def __init__(self, run: Run, wrap: Callable[[list], list]) -> None:
199
+ self._run = run
200
+ self._wrap = wrap
201
+ self.raw: dict[str, str] = {}
202
+
203
+ def read(self, cmd: tuple) -> _Got:
204
+ got = _get(self._run, self._wrap(list(cmd)))
205
+ if got.out is not None:
206
+ self.raw[_key(cmd)] = got.out
207
+ return got
208
+
209
+ def list_packages(self, distro: str) -> None:
210
+ """Read the listings that fit distro in order, and stop at the first one with packages."""
211
+ for cmd in _managers(distro):
212
+ self.read(cmd)
213
+ if _listing(distro, self.raw) is not None:
214
+ return
215
+
216
+
217
+ def _key(cmd: tuple) -> str:
218
+ return " ".join(cmd)
219
+
220
+
221
+ def _fits(value: str, chars: frozenset, limit: int) -> bool:
222
+ return 0 < len(value) <= limit and chars.issuperset(value)
223
+
224
+
225
+ def _version(token: str) -> Optional[PhpVersion]:
226
+ """The version a token spells, or None when it is no version or longer than any real one."""
227
+ if not _fits(token, _VERSION_CHARS, _MAX_VERSION):
228
+ return None
229
+ return PhpVersion.parse(token)
230
+
231
+
232
+ def _upstream(version: PhpVersion) -> str:
233
+ """The upstream number. A distro revision is cut off, and a pre-release tag stays, because the
234
+ grader reads 8.3.36-dev as a build that may not hold the fix."""
235
+ base = f"{version.major}.{version.minor}.{version.micro}"
236
+ return base if _is_revision(version) else base + version.suffix
237
+
238
+
239
+ def _banner(text: str) -> Optional[tuple[str, str]]:
240
+ """(version, sapi) from the first line of php -v that reads like PHP 8.3.35 (cli)."""
241
+ for line in text.splitlines():
242
+ word, _, rest = line.partition(" ")
243
+ token, _, tail = rest.partition(" ")
244
+ if word == "PHP" and _version(token) is not None:
245
+ tail = tail.strip()
246
+ api = tail[1:].partition(")")[0] if tail.startswith("(") else ""
247
+ return token, api.strip().lower()
248
+ return None
249
+
250
+
251
+ def _first(text: str, names: frozenset) -> dict[str, str]:
252
+ """The local value of each name from the name => value lines that php -i prints.
253
+
254
+ The first line for a name decides. The Environment section comes after the directives, and a
255
+ variable can be called anything.
256
+ """
257
+ found: dict[str, str] = {}
258
+ for line in text.splitlines():
259
+ name, arrow, rest = line.partition(" => ")
260
+ if arrow and name in names and name not in found:
261
+ found[name] = rest.partition(" => ")[0].strip()
262
+ return found
263
+
264
+
265
+ def collect_ini(raw: dict) -> dict:
266
+ """The ini settings that bear on PHP2Shell, taken from what php -i printed.
267
+
268
+ raw maps a command line such as "php -i" to its output. A setting is recorded as php -i
269
+ shows it for this run, and a value that is not a short plain word is left out. These are
270
+ annotations. Nothing here changes the version that the grader reads.
271
+ """
272
+ found = _first(raw.get(_key(_I)) or "", _INI)
273
+ return {name: value for name, value in found.items()
274
+ if _fits(value, _VALUE_CHARS, _MAX_VALUE)}
275
+
276
+
277
+ class _Php(NamedTuple):
278
+ # token is the version text as php printed it, revision included. It has passed the checks
279
+ # only when version is set, so nothing reads it otherwise.
280
+ token: str
281
+ version: Optional[PhpVersion]
282
+ sapi: Sapi
283
+
284
+
285
+ def _php(raw: dict) -> Optional[_Php]:
286
+ """What php -v and php -i say about the PHP that answered, or None when neither shows PHP."""
287
+ banner = _banner(raw.get(_key(_V)) or "")
288
+ info = _first(raw.get(_key(_I)) or "", _WANTED)
289
+ if banner is None and not info:
290
+ return None
291
+ token, api = banner if banner else (info.get("PHP Version", ""), "")
292
+ sapi = _SAPIS.get(api, Sapi.UNKNOWN)
293
+ if sapi is Sapi.UNKNOWN:
294
+ sapi = _SAPIS.get(info.get("Server API", "").lower(), Sapi.UNKNOWN)
295
+ return _Php(token, _version(token), sapi)
296
+
297
+
298
+ def _os_release(text: str) -> tuple[str, str]:
299
+ """(ID, VERSION_ID) from os-release. Either is "" when it is missing or not a plain word."""
300
+ found: dict[str, str] = {}
301
+ for line in text.splitlines():
302
+ key, eq, value = line.partition("=")
303
+ if eq and key in ("ID", "VERSION_ID") and key not in found:
304
+ found[key] = value.strip().strip("\"'")
305
+ ident, release = found.get("ID", "").lower(), found.get("VERSION_ID", "")
306
+ return (ident if _fits(ident, _NAME, _MAX_ID) else "",
307
+ release if _fits(release, _NAME, _MAX_ID) else "")
308
+
309
+
310
+ def _no_epoch(version: str) -> str:
311
+ epoch, colon, rest = version.partition(":")
312
+ return rest if colon and epoch.isdigit() else version
313
+
314
+
315
+ def _deb(text: str) -> list[tuple[str, str]]:
316
+ """(name, version) for each line of dpkg-query -W. The epoch and the architecture are cut.
317
+
318
+ A real version starts with a digit. Requiring that keeps an error message with a tab in it
319
+ from passing as a listing, because an empty or invented listing could clear a host.
320
+ """
321
+ found = []
322
+ for line in text.splitlines():
323
+ name, _, version = line.partition("\t")
324
+ name, colon, arch = name.partition(":")
325
+ version = _no_epoch(version.strip())
326
+ if (_fits(name, _DEB_NAME, _MAX_NAME) and (not colon or _fits(arch, _DEB_NAME, _MAX_NAME))
327
+ and _fits(version, _DEB_VERSION, _MAX_BUILD) and version[0].isdigit()):
328
+ found.append((name, version))
329
+ return found
330
+
331
+
332
+ def _rpm(text: str) -> list[tuple[str, str]]:
333
+ """(name, version-release) for each name-version-release.arch line of rpm -qa.
334
+
335
+ The version has to start with a digit, so hyphenated words in an error message do not pass.
336
+ """
337
+ found = []
338
+ for line in text.splitlines():
339
+ rest, _, release = line.strip().rpartition("-")
340
+ name, _, version = rest.rpartition("-")
341
+ stem, dot, arch = release.rpartition(".")
342
+ if dot and arch in _ARCHES:
343
+ release = stem
344
+ if (_fits(name, _RPM_NAME, _MAX_NAME) and _fits(version, _RPM_CHARS, _MAX_BUILD)
345
+ and version[0].isdigit() and _fits(release, _RPM_CHARS, _MAX_BUILD)
346
+ and len(version) + len(release) < _MAX_BUILD):
347
+ found.append((name, f"{version}-{release}"))
348
+ return found
349
+
350
+
351
+ _PARSERS = {_key(_DPKG): _deb, _key(_RPM): _rpm}
352
+
353
+
354
+ def _managers(distro: str) -> tuple:
355
+ """The listing commands to try for a distro. Debian and Ubuntu use dpkg, the rpm distros use
356
+ rpm, and any other distro gets dpkg and then rpm."""
357
+ if distro in _DEB_DISTROS:
358
+ return (_DPKG,)
359
+ if distro in _RPM_DISTROS:
360
+ return (_RPM,)
361
+ return (_DPKG, _RPM)
362
+
363
+
364
+ def _listing(distro: str, raw: dict) -> Optional[list[tuple[str, str]]]:
365
+ """The packages in the first listing of raw that has any, or None when none does."""
366
+ for cmd in _managers(distro):
367
+ packages = _PARSERS[_key(cmd)](raw.get(_key(cmd)) or "")
368
+ if packages:
369
+ return packages
370
+ return None
371
+
372
+
373
+ def _php_package(name: str) -> bool:
374
+ # For matching a build to the running version, which has to be exact. php, php-cli,
375
+ # php8.1-fpm, php83 and php8-cli are PHP. phpmyadmin and phpunit are not.
376
+ return name.startswith("php") and (len(name) == 3 or name[3] in string.digits + "-")
377
+
378
+
379
+ def _mentions_php(name: str) -> bool:
380
+ # For deciding that a host has no PHP, which has to catch every packaging: plesk-php81-cli,
381
+ # ea-php81-php-cli, lsphp81, alt-php81-cli, rh-php73-php-fpm, libapache2-mod-php8.1, and
382
+ # libphp8.1-embed. A miss would clear a host that runs PHP, so phpmyadmin counts too.
383
+ return "php" in name.lower()
384
+
385
+
386
+ class _Package(NamedTuple):
387
+ # found: one installed build matches the running PHP. none: a listing was read and no PHP
388
+ # package matches. mixed: packages of the running version disagree on the build. unlisted: no
389
+ # listing could be read. unknown: php gave no version to match.
390
+ state: str
391
+ build: Optional[str] = None
392
+ revision: Optional[str] = None
393
+
394
+
395
+ def _resolve(distro: str, raw: dict) -> _Package:
396
+ php = _php(raw)
397
+ if php is None or php.version is None:
398
+ return _Package("unknown")
399
+ packages = _listing(distro, raw)
400
+ if packages is None:
401
+ return _Package("unlisted")
402
+ # A running version with a revision on it is the build itself, so only a package with exactly
403
+ # that build counts. Without one, any package built from the same upstream number does.
404
+ revision = _is_revision(php.version)
405
+ builds = {build for name, build in packages if _php_package(name)
406
+ and PhpVersion.parse(build) == php.version
407
+ and (build == php.token or not revision)}
408
+ if len(builds) != 1:
409
+ return _Package("mixed" if builds else "none")
410
+ (build,) = builds
411
+ _, hyphen, tail = build.rpartition("-")
412
+ return _Package("found", build, tail if hyphen else None)
413
+
414
+
415
+ def resolve_package(distro: str, raw: dict) -> tuple[Optional[str], Optional[str]]:
416
+ """The installed build of the PHP that php reports, and its distro revision: (build, revision).
417
+
418
+ raw maps a command line, such as "php -v", "dpkg-query -W", or "rpm -qa", to what it printed.
419
+ distro is a key from the matrix data file: ubuntu, debian, alpine, or suse. It picks the
420
+ package manager whose listing counts. Debian and Ubuntu use dpkg, the rpm distros use rpm,
421
+ and any other distro tries dpkg and then rpm.
422
+
423
+ The build comes from the PHP package built from the running version, with the epoch and the
424
+ architecture cut. The revision is what follows its last hyphen, the mark of the distro's own
425
+ rebuild. When php gave no version, no listing could be read, no package matches, or the
426
+ matching packages disagree, the answer is (None, None). Nothing is guessed.
427
+ """
428
+ pkg = _resolve(distro, raw)
429
+ return pkg.build, pkg.revision
430
+
431
+
432
+ def _unconfirmed(pkg: _Package, php: _Php, distro: str) -> str:
433
+ """Why the distro of this PHP is not confirmed, or "" when nothing is in doubt."""
434
+ if pkg.state == "unlisted":
435
+ return "no package listing could be read"
436
+ if pkg.state == "mixed":
437
+ return "installed PHP packages carry different builds of this version"
438
+ if pkg.state == "none" and php.version is not None and _is_revision(php.version):
439
+ return "php reports a distro build that no installed package lists"
440
+ if pkg.state == "found" and not distro:
441
+ return "the distro name could not be read"
442
+ return ""
443
+
444
+
445
+ def _who(host: str) -> dict[str, str]:
446
+ # An IPv6 address can start with a hex letter, which would pass for the last label of a name.
447
+ return {"ip": host} if ":" in host else _identity(host)
448
+
449
+
450
+ def _link(got: _Got) -> str:
451
+ if got.code == _SSH_FAILED:
452
+ # A php that dies at startup exits 255 as well, so the note does not claim more than that.
453
+ return f"ssh exited {_SSH_FAILED} (the connection, the login, or the remote command failed)"
454
+ return "ssh was not found" if got.missing else f"ssh {got.why}"
455
+
456
+
457
+ def _unread(name: str, got: _Got) -> str:
458
+ return f"{name} gave no PHP output" if got.out is not None else f"{name} {got.why}"
459
+
460
+
461
+ def _without_php(m: _Machine, location: str, who: dict[str, str], v: _Got, i: _Got) -> Evidence:
462
+ signals = dict(who)
463
+ if not (v.missing and i.missing):
464
+ note = f"php could not be read ({_unread('php -v', v)}; {_unread('php -i', i)})"
465
+ return Evidence(Source.HOST, location, None, Sapi.UNKNOWN, signals, note)
466
+ # Both reads say there is no php command. An install with only php-fpm or an Apache module has
467
+ # no such command, so the package manager has to agree before this counts as no PHP.
468
+ m.list_packages("")
469
+ packages = _listing("", m.raw)
470
+ if packages is None:
471
+ note = ("no php command was found and no package listing could be read: "
472
+ "PHP may still be installed")
473
+ elif any(_mentions_php(name) for name, _ in packages):
474
+ signals["php_present"] = "true"
475
+ note = ("no php command was found, but a package with php in its name is installed: "
476
+ "the build was not read")
477
+ else:
478
+ signals["php_present"] = "false"
479
+ note = ""
480
+ return Evidence(Source.HOST, location, None, Sapi.UNKNOWN, signals, note)
481
+
482
+
483
+ def _with_php(m: _Machine, location: str, who: dict[str, str], i: _Got, php: _Php) -> Evidence:
484
+ m.read(_OS)
485
+ ident, release = _os_release(m.raw.get(_key(_OS), ""))
486
+ distro = _ALIASES.get(ident, ident)
487
+ if php.version is not None:
488
+ m.list_packages(distro)
489
+ pkg = _resolve(distro, m.raw)
490
+
491
+ signals = dict(who)
492
+ signals["php_present"] = "true"
493
+ if ident:
494
+ signals["os"] = f"{ident} {release}" if release else ident
495
+ if pkg.state == "found":
496
+ signals["package_version"] = pkg.build
497
+ if distro:
498
+ signals["distro"] = distro
499
+ elif php.version is not None and _is_revision(php.version):
500
+ signals["package_version"] = php.token
501
+ signals.update(collect_ini(m.raw))
502
+
503
+ notes = []
504
+ if i.out is None:
505
+ notes.append(f"php -i {i.why}: ini settings not recorded")
506
+ if php.version is None:
507
+ notes.append("the PHP version could not be read")
508
+ doubt = _unconfirmed(pkg, php, distro)
509
+ if doubt:
510
+ notes.append("provenance unconfirmed: " + doubt)
511
+ detected = _upstream(php.version) if php.version else None
512
+ return Evidence(Source.HOST, location, detected, php.sapi, signals, "; ".join(notes))
513
+
514
+
515
+ def _inspect(m: _Machine, location: str, who: dict[str, str], remote: bool) -> Evidence:
516
+ v = m.read(_V)
517
+ if remote and v.code in (None, _SSH_FAILED):
518
+ # The link is down, so every other read would fail the same way and wait as long.
519
+ return Evidence(Source.HOST, location, None, Sapi.UNKNOWN, dict(who),
520
+ "not inspected: " + _link(v))
521
+ i = m.read(_I)
522
+ php = _php(m.raw)
523
+ if php is None:
524
+ return _without_php(m, location, who, v, i)
525
+ return _with_php(m, location, who, i, php)
526
+
527
+
528
+ def inspect_local(run: Run = subprocess_run) -> Evidence:
529
+ """One HOST Evidence for the PHP on this machine.
530
+
531
+ run is called with a list of strings and returns what subprocess.run returns. The evidence is
532
+ named for the machine by hostname, or by ip when the name is an address.
533
+ """
534
+ name = socket.gethostname()
535
+ who = _who(name) if _fits(name, _NAME, _MAX_HOST) else {}
536
+ return _inspect(_Machine(run, list), name if who else "localhost", who, remote=False)
537
+
538
+
539
+ def _ssh_host(target: object) -> Optional[str]:
540
+ """The host in a [user@]host target, or None when the target is anything else.
541
+
542
+ A leading hyphen would read as an ssh option, and anything past name characters could read as
543
+ more than a name to a shell on either side. The user name is checked for the same reasons.
544
+ """
545
+ if not isinstance(target, str):
546
+ return None
547
+ user, at, host = target.rpartition("@")
548
+ if at and not (_fits(user, _NAME, _MAX_USER) and user[0] != "-"):
549
+ return None
550
+ if not (_fits(host, _HOST, _MAX_HOST) and host[0] in _HOST_START):
551
+ return None
552
+ return host
553
+
554
+
555
+ def inspect_ssh(target: str, run: Run = subprocess_run) -> Evidence:
556
+ """One HOST Evidence for the PHP on the machine at target, a host name or address with an
557
+ optional user@ in front.
558
+
559
+ The commands run through the system ssh client, with the target and each word of the remote
560
+ command as separate arguments. The evidence is named for the host in the target, the name the
561
+ operator supplied, and never for a name the machine gives itself, because a machine under
562
+ suspicion could claim another host's name. A target that is not a plain host is refused before
563
+ anything runs.
564
+ """
565
+ host = _ssh_host(target)
566
+ if host is None:
567
+ return Evidence(Source.HOST, "ssh", None, Sapi.UNKNOWN, {},
568
+ "not inspected: the ssh target must be a host name or address, "
569
+ "with an optional user@ in front")
570
+ machine = _Machine(run, lambda cmd: _SSH + ["--", target] + cmd)
571
+ return _inspect(machine, host, _who(host), remote=True)