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.
- php_radar/__init__.py +0 -0
- php_radar/__main__.py +6 -0
- php_radar/cli.py +375 -0
- php_radar/data/php2shell.json +57 -0
- php_radar/http.py +276 -0
- php_radar/knowledge.py +339 -0
- php_radar/model.py +143 -0
- php_radar/report.py +430 -0
- php_radar/scope.py +174 -0
- php_radar/sources/__init__.py +0 -0
- php_radar/sources/cloud/__init__.py +64 -0
- php_radar/sources/cloud/aws.py +435 -0
- php_radar/sources/github.py +399 -0
- php_radar/sources/host.py +571 -0
- php_radar/sources/network.py +375 -0
- php_radar/sources/repo.py +579 -0
- php_radar/versions.py +51 -0
- php_radar-0.1.0.dist-info/METADATA +97 -0
- php_radar-0.1.0.dist-info/RECORD +24 -0
- php_radar-0.1.0.dist-info/WHEEL +5 -0
- php_radar-0.1.0.dist-info/entry_points.txt +2 -0
- php_radar-0.1.0.dist-info/licenses/LICENSE +202 -0
- php_radar-0.1.0.dist-info/licenses/NOTICE +4 -0
- php_radar-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|