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,375 @@
1
+ """Probes a live host over the network and reads what its web server says about PHP.
2
+
3
+ probe sends one GET for the root path and nothing else: no body, no query, no credential, and
4
+ nothing built to trigger PHP2Shell. It reads the headers of the answer and, on an https port, the
5
+ certificate. sweep runs it over the targets that guard returned.
6
+
7
+ The scope is the authorization boundary, so nothing here leaves it. A redirect is never followed.
8
+ http.get returns the 3xx like any other answer, its headers count as evidence, and its Location
9
+ is never requested or looked up. The certificate is not verified, because a scanner reads what a
10
+ host presents and believes none of it. For the same reason a name in a certificate is recorded
11
+ and never used to identify the asset. A certificate can claim any name. A vulnerable box that
12
+ claimed a patched host's name would merge into that host's asset, and the patched read would
13
+ clear it.
14
+
15
+ Treat a banner as a lead. It can be hidden, rewritten, or sent by a proxy in front of the real
16
+ server.
17
+ """
18
+ import ipaddress
19
+ import itertools
20
+ import math
21
+ import string
22
+ import threading
23
+ import time
24
+ from concurrent.futures import ThreadPoolExecutor
25
+ from typing import Callable, Iterator, Optional
26
+ from urllib.parse import urlsplit
27
+
28
+ from php_radar import http
29
+ from php_radar.model import Evidence, Sapi, Source
30
+ from php_radar.scope import Target
31
+ from php_radar.versions import PhpVersion
32
+
33
+ # Seconds, for each wait on the socket: the connect, the handshake, and each read.
34
+ _TIMEOUT = 5.0
35
+
36
+ # A scope entry has no scheme, so the port decides. A TLS service on any other port is probed in
37
+ # plain HTTP, gets no usable answer, and shows up as a row that says so.
38
+ _TLS_PORTS = frozenset({443, 8443})
39
+
40
+ # The request names the tool, so an operator reading their logs can tell the scan from an attack.
41
+ # It carries no cookie and no credential.
42
+ _REQUEST = {"User-Agent": "php-radar", "Accept": "*/*"}
43
+
44
+ # An answer with one of these statuses is the gate's own: a login, a firewall, or a rate limit. A
45
+ # vulnerable application can sit behind any of them, and the answer shows nothing about it.
46
+ _GATED = frozenset({401, 403, 429})
47
+
48
+ _NAME_CHARS = frozenset(string.ascii_letters + string.digits + "-.")
49
+ _VERSION_CHARS = frozenset(string.ascii_letters + string.digits + ".+~_-")
50
+
51
+ # The longest build string taken from a banner. Real ones run to about 45 characters.
52
+ _MAX_VERSION = 64
53
+
54
+ # The most names kept from a certificate, and the longest one. A DNS name is 253 characters at most.
55
+ _MAX_NAMES = 32
56
+ _MAX_NAME = 253
57
+
58
+ _OID_CN = bytes.fromhex("550403")
59
+ _OID_SAN = bytes.fromhex("551d11")
60
+
61
+ # The DER string types a name can come in, and how each is encoded.
62
+ _STRING_CODECS = {0x0C: "utf-8", 0x13: "utf-8", 0x14: "utf-8", 0x16: "utf-8", 0x1E: "utf-16-be"}
63
+
64
+ # Lower-casing only A to Z keeps every index of the result on the same character of the original.
65
+ # str.lower() turns one capital I with a dot above (U+0130) into two characters.
66
+ _ASCII_LOWER = {ord(c): ord(c.lower()) for c in string.ascii_uppercase}
67
+
68
+ # Pacing goes through these two names so that a test never sleeps.
69
+ _sleep = time.sleep
70
+ _now = time.monotonic
71
+
72
+
73
+ def _fold(text: str) -> str:
74
+ return text.translate(_ASCII_LOWER)
75
+
76
+
77
+ def _lowered(headers: dict) -> dict[str, str]:
78
+ return {_fold(str(name)): str(value) for name, value in headers.items()}
79
+
80
+
81
+ def _versions(value: str) -> Iterator[tuple[PhpVersion, str]]:
82
+ """Each PHP version that follows a PHP/ token in one header value."""
83
+ folded = _fold(value)
84
+ at = folded.find("php/")
85
+ while at != -1:
86
+ begin = at + len("php/")
87
+ end = begin
88
+ while end < len(value) and value[end] in _VERSION_CHARS:
89
+ end += 1
90
+ text = value[begin:end]
91
+ # CakePHP/4.2.1 names another product, so PHP has to start a word. A build longer than any
92
+ # real one is hostile, and cutting it down would hand the grader a build no server sent.
93
+ if (at == 0 or not value[at - 1].isalnum()) and len(text) <= _MAX_VERSION:
94
+ version = PhpVersion.parse(text)
95
+ if version is not None:
96
+ yield version, text
97
+ at = folded.find("php/", begin)
98
+
99
+
100
+ def fingerprint_version(headers: dict) -> Optional[str]:
101
+ """The PHP version in the Server or X-Powered-By header, or None when neither gives one.
102
+
103
+ The version is the text after PHP/. An Apache or nginx version in Server is ignored. A distro
104
+ revision such as -1ubuntu2.14 stays on it, because the grader reads one as a packaged build
105
+ and does not grade it on the upstream number. Header names may be in any case. When two
106
+ banners disagree the lower version is returned. Each is only a claim, and the lower one could
107
+ be exposed.
108
+ """
109
+ found = []
110
+ for name, value in _lowered(headers).items():
111
+ if name in ("server", "x-powered-by"):
112
+ found.extend(_versions(value))
113
+ return min(found, key=lambda pair: pair[0])[1] if found else None
114
+
115
+
116
+ def _is_script(location: str) -> bool:
117
+ """True when a Location points at a .php file. Only the path counts, so php.net does not."""
118
+ try:
119
+ path = urlsplit(location).path
120
+ except ValueError:
121
+ return False # a Location that does not parse names no script
122
+ return ".php" in _fold(path)
123
+
124
+
125
+ def _names_php(headers: dict[str, str]) -> bool:
126
+ """True when the answer shows PHP in a header, whether or not it gives a version."""
127
+ return ("php" in _fold(headers.get("x-powered-by", ""))
128
+ or "php" in _fold(headers.get("server", ""))
129
+ or "phpsessid" in _fold(headers.get("set-cookie", ""))
130
+ or "php" in _fold(headers.get("content-type", ""))
131
+ or _is_script(headers.get("location", "")))
132
+
133
+
134
+ def _says_nothing(status: int) -> str:
135
+ """Why an answer with this status and no sign of PHP tells nothing about the site, or ""."""
136
+ if status >= 500:
137
+ return "a server error" # a gateway, or an application that did not run
138
+ return "access blocked or gated" if status in _GATED else ""
139
+
140
+
141
+ def _url(target: Target) -> Optional[str]:
142
+ """The URL to request for target, or None when target is not a plain host and port."""
143
+ host, port = target.host, target.port
144
+ # The host goes straight into a URL, and anything past name characters could point the request
145
+ # at a host the scope never named: user@other, a/b, h:80.
146
+ if not (isinstance(host, str) and 0 < len(host) <= 253 and _NAME_CHARS.issuperset(host)
147
+ and type(port) is int and 0 < port < 65536):
148
+ return None
149
+ return f"{'https' if port in _TLS_PORTS else 'http'}://{host}:{port}/"
150
+
151
+
152
+ def _identity(host: str) -> dict[str, str]:
153
+ # The scope file tells a name from an address by the last label. The last label of a name
154
+ # starts with a letter. The last label of every spelling the OS resolver reads as an address
155
+ # (2130706433, 0x7f.1, 127.1) starts with a digit. An address goes in ip, because the model
156
+ # merges on hostname and a literal there would join assets that it must keep apart.
157
+ last = host.rstrip(".").rpartition(".")[2]
158
+ return {"hostname": host} if last[:1].isalpha() else {"ip": host}
159
+
160
+
161
+ def _why(exc: OSError) -> str:
162
+ # A TransportError is worded to be shown. Any other error names its class, because its own
163
+ # text alone says too little. A hostile server can put text in the message, so it is cut.
164
+ text = str(exc) if isinstance(exc, http.TransportError) else f"{type(exc).__name__}: {exc}"
165
+ return text[:200]
166
+
167
+
168
+ def _elements(data: bytes, pos: int, end: int) -> Iterator[tuple[int, int, int]]:
169
+ """The (tag, start, end) of each DER element in data[pos:end], where start and end bound the
170
+ content of the element.
171
+
172
+ Anything that is not well-formed DER ends the walk: a length that runs past end, an
173
+ indefinite length, or a tag number past 30 (X.509 names use none).
174
+ """
175
+ while pos + 2 <= end:
176
+ tag, size = data[pos], data[pos + 1]
177
+ pos += 2
178
+ if tag & 0x1F == 0x1F:
179
+ return
180
+ if size >= 0x80:
181
+ count = size & 0x7F
182
+ if not 0 < count <= 3:
183
+ return
184
+ size = int.from_bytes(data[pos:pos + count], "big")
185
+ pos += count
186
+ if pos + size > end:
187
+ return
188
+ yield tag, pos, pos + size
189
+ pos += size
190
+
191
+
192
+ def _is_oid(data: bytes, element: tuple[int, int, int], oid: bytes) -> bool:
193
+ return element[0] == 0x06 and data[element[1]:element[2]] == oid
194
+
195
+
196
+ def _text(data: bytes, tag: int, start: int, end: int) -> str:
197
+ """The text of a DER string, or "" when it is no text, not printable, or longer than a name."""
198
+ codec = _STRING_CODECS.get(tag)
199
+ if codec is None:
200
+ return ""
201
+ text = data[start:end].decode(codec, "replace")
202
+ return text if text.isprintable() and len(text) <= _MAX_NAME else ""
203
+
204
+
205
+ def _common_names(der: bytes, subject: tuple[int, int, int]) -> Iterator[str]:
206
+ for _, rdn, rdn_end in _elements(der, subject[1], subject[2]):
207
+ for _, start, end in _elements(der, rdn, rdn_end):
208
+ pair = list(_elements(der, start, end))
209
+ if len(pair) == 2 and _is_oid(der, pair[0], _OID_CN):
210
+ text = _text(der, *pair[1])
211
+ if text:
212
+ yield text
213
+
214
+
215
+ def _general_names(der: bytes, octets: tuple[int, int, int]) -> Iterator[str]:
216
+ for tag, start, end in _elements(der, octets[1], octets[2]):
217
+ if tag != 0x30:
218
+ continue
219
+ for kind, pos, stop in _elements(der, start, end):
220
+ raw = der[pos:stop]
221
+ if kind == 0x82 and raw.isascii() and 0 < len(raw) <= _MAX_NAME:
222
+ text = raw.decode("ascii")
223
+ if text.isprintable():
224
+ yield text
225
+ elif kind == 0x87 and len(raw) in (4, 16):
226
+ yield str(ipaddress.ip_address(raw))
227
+
228
+
229
+ def _alt_names(der: bytes, fields: list[tuple[int, int, int]]) -> Iterator[str]:
230
+ for tag, start, end in fields:
231
+ if tag != 0xA3: # [3], the extensions
232
+ continue
233
+ for _, ext_start, ext_end in _elements(der, start, end):
234
+ for _, pos, stop in _elements(der, ext_start, ext_end):
235
+ parts = list(_elements(der, pos, stop))
236
+ # The extension is its OID, an optional critical flag, and an OCTET STRING.
237
+ if len(parts) >= 2 and _is_oid(der, parts[0], _OID_SAN) and parts[-1][0] == 0x04:
238
+ yield from _general_names(der, parts[-1])
239
+
240
+
241
+ def _cert_signals(der: Optional[bytes]) -> dict[str, str]:
242
+ """The names an X.509 certificate gives itself, as signals.
243
+
244
+ cert_cn lists the common names in the subject. cert_san lists the DNS names and addresses in
245
+ the subject alternative names. Each list is comma separated and cut at _MAX_NAMES. A name that
246
+ is not printable, or is longer than any name, is left out. The DER comes from the host being
247
+ probed, so every length is checked against the bytes that remain, and anything unreadable
248
+ ends the walk with fewer names. Nothing is verified, and the names are only the host's claims.
249
+ """
250
+ if not der:
251
+ return {}
252
+ outer = next(_elements(der, 0, len(der)), None)
253
+ if outer is None or outer[0] != 0x30:
254
+ return {}
255
+ # The first element inside is the part of the certificate that is signed.
256
+ tbs = next(_elements(der, outer[1], outer[2]), None)
257
+ if tbs is None or tbs[0] != 0x30:
258
+ return {}
259
+ fields = list(_elements(der, tbs[1], tbs[2]))
260
+ # A version 1 certificate has no [0] version. After it come the serial number, the signature
261
+ # algorithm, the issuer, the validity, and then the subject.
262
+ first = 1 if fields and fields[0][0] == 0xA0 else 0
263
+ if len(fields) <= first + 4 or fields[first + 4][0] != 0x30:
264
+ return {}
265
+ found = {
266
+ "cert_cn": list(itertools.islice(_common_names(der, fields[first + 4]), _MAX_NAMES)),
267
+ "cert_san": list(itertools.islice(_alt_names(der, fields[first + 5:]), _MAX_NAMES)),
268
+ }
269
+ return {key: ", ".join(names) for key, names in found.items() if names}
270
+
271
+
272
+ def probe(target: Target, timeout: float, get: Callable[..., http.Response] = http.get) -> Evidence:
273
+ """One Evidence for what the server at target shows about PHP.
274
+
275
+ The request is a GET of / with no body and no credential, made without checking the
276
+ certificate. Only the headers of the answer are read. A redirect is never followed. The
277
+ location of the evidence is the URL that was requested. Written host:port it would read as
278
+ name:value to the redactor, and a host called token or keycloak would lose its port.
279
+
280
+ detected_version is the PHP version in the Server or X-Powered-By header, or None.
281
+
282
+ The target is recorded as hostname when it is a name and as ip when it is an address. Only
283
+ the hostname lets the asset merge with a host or cloud read of the same machine.
284
+
285
+ php_present is "true" when the answer shows PHP: a version, PHP in X-Powered-By or Server, a
286
+ PHPSESSID cookie, a PHP content type (a server sends one when it serves a script's source),
287
+ or a Location that points at a .php file. It is "false" when the host answered and shows
288
+ none. It is absent when the answer tells nothing about the site: a server error, or a 401,
289
+ 403 or 429, that shows no sign of PHP. Those keep a note with the reason.
290
+
291
+ cert_cn and cert_san hold the names in the certificate of an https server. They are recorded
292
+ and identify nothing.
293
+
294
+ A host that cannot be reached, or that answers with something other than HTTP, gets a note
295
+ with the reason, and probe does not raise for it. An unusable timeout raises ValueError, and
296
+ a target that is not a plain host and port is refused. Nothing is sent in either case.
297
+ """
298
+ url = _url(target)
299
+ if url is None:
300
+ return Evidence(Source.NETWORK, f"{target.host}:{target.port}", None, Sapi.UNKNOWN, {},
301
+ "not probed: the target is not a host name or IPv4 address with a port")
302
+ signals = _identity(target.host)
303
+ try:
304
+ answer = get(url, dict(_REQUEST), timeout, verify=False, read_body=False)
305
+ except OSError as exc:
306
+ return Evidence(Source.NETWORK, url, None, Sapi.UNKNOWN, signals,
307
+ "no answer: " + _why(exc))
308
+ headers = _lowered(answer.headers)
309
+ version = fingerprint_version(headers)
310
+ note = ""
311
+ if version is not None or _names_php(headers):
312
+ signals["php_present"] = "true"
313
+ else:
314
+ reason = _says_nothing(answer.status)
315
+ if reason:
316
+ # Silence is no clean bill, so the host stays in the findings with the reason.
317
+ note = f"HTTP {answer.status}, {reason}: could not determine what runs the site"
318
+ else:
319
+ signals["php_present"] = "false"
320
+ signals.update(_cert_signals(answer.cert))
321
+ return Evidence(Source.NETWORK, url, version, Sapi.UNKNOWN, signals, note)
322
+
323
+
324
+ class _Pacer:
325
+ """Spaces the start of probes at least 1/rate seconds apart, across every thread."""
326
+
327
+ def __init__(self, rate: float) -> None:
328
+ self._gap = 1.0 / rate
329
+ self._lock = threading.Lock()
330
+ self._next = 0.0
331
+
332
+ def wait(self) -> None:
333
+ # Each caller reserves a slot under the lock and sleeps outside it, so a long wait for one
334
+ # slot does not hold up the reservation of the next.
335
+ with self._lock:
336
+ now = _now()
337
+ start = max(now, self._next)
338
+ self._next = start + self._gap
339
+ if start > now:
340
+ _sleep(start - now)
341
+
342
+
343
+ def sweep(targets: list[Target], rate: float, workers: int, *, timeout: float = _TIMEOUT,
344
+ get: Callable[..., http.Response] = http.get) -> list[Evidence]:
345
+ """The evidence for each target that shows PHP or could not be read, in the order of targets.
346
+
347
+ targets is the list that guard returned. Each is probed once and none is added. A host that
348
+ answered and shows no sign of PHP leaves no evidence, so a clean perimeter gives an empty
349
+ list. A host that gave no answer keeps its row. So does one that answered with a server
350
+ error, or a 401, 403 or 429, and nothing else. The reason is in note. Silence is not a clean
351
+ bill.
352
+
353
+ rate is probes started per second across all workers. workers is how many can be waiting on a
354
+ host at once. rate must be a positive finite number and workers a whole number of at least 1,
355
+ or ValueError says so before anything is sent. timeout limits each wait on the socket, and
356
+ http.get ends the read of an answer after four of them, so a host that drips its answer in
357
+ cannot hold a worker. Nothing caps a trickled TLS handshake as a whole, and a name that is
358
+ slow to resolve is not capped at all. An error that is no failure to reach a host propagates
359
+ and drops the probes that have not started.
360
+ """
361
+ if isinstance(rate, bool) or not isinstance(rate, (int, float)) or not 0 < rate < math.inf:
362
+ raise ValueError("rate must be a positive number of probes per second")
363
+ if isinstance(workers, bool) or not isinstance(workers, int) or workers < 1:
364
+ raise ValueError("workers must be a whole number, 1 or more")
365
+ pacer = _Pacer(rate)
366
+
367
+ def one(target: Target) -> Evidence:
368
+ pacer.wait()
369
+ return probe(target, timeout, get)
370
+
371
+ # An error or an interrupt leaves map, and map cancels the probes that have not started. The
372
+ # ones running end within their timeouts.
373
+ with ThreadPoolExecutor(max_workers=workers) as pool:
374
+ found = list(pool.map(one, targets))
375
+ return [ev for ev in found if ev.signals.get("php_present") != "false"]