phpscan 1.4.4__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.
phpscan/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """phpscan — the free, open-source external PHP scanner from
2
+ PHP Patch Assurance (https://phpatch.com).
3
+
4
+ Passive fingerprinting plus limited, read-only checks of a few
5
+ well-known paths (the phpinfo exposure probes): it reads what a
6
+ site already discloses publicly (headers, generator tags, exposed
7
+ phpinfo pages) and judges any disclosed PHP version against the
8
+ advisory database in data/cves.json. It never runs exploit probes.
9
+
10
+ When a site hides its PHP version — most well-run sites do — the
11
+ verdict is an honest INDETERMINATE, with the path to a certain
12
+ answer (a read-only server check) explained at https://phpatch.com.
13
+ """
14
+
15
+ __version__ = "1.4.4"
phpscan/__main__.py ADDED
@@ -0,0 +1,52 @@
1
+ """CLI: python3 -m phpscan <url> [more urls...]
2
+
3
+ Prints one JSON verdict object per URL.
4
+
5
+ Exit codes: 0 — scanned, no actionable finding; 1 — at least one
6
+ target is vulnerable, possibly vulnerable, or on an end-of-life
7
+ branch; 2 — usage error, or a target could not be judged at all:
8
+ unreachable, or `not_assessed` (its PHP branch is newer than the
9
+ advisory database, so the scanner cannot judge it). When several
10
+ targets are scanned, the worst code wins.
11
+ """
12
+
13
+ import json
14
+ import sys
15
+
16
+ from .scan import scan
17
+
18
+ _ACTIONABLE = ("vulnerable", "possibly_vulnerable", "eol")
19
+ _UNJUDGED = ("not_assessed", "unreachable")
20
+
21
+
22
+ def main(argv):
23
+ argv = [a for a in argv if a != ""]
24
+ if not argv:
25
+ print(__doc__)
26
+ return 2
27
+ if any(a in ("-h", "--help") for a in argv):
28
+ print(__doc__)
29
+ return 0
30
+ worst = 0
31
+ for url in argv:
32
+ try:
33
+ r = scan(url)
34
+ print(json.dumps(r, indent=1))
35
+ status = (r.get("verdict") or {}).get("status")
36
+ if status in _ACTIONABLE:
37
+ worst = max(worst, 1)
38
+ elif status in _UNJUDGED:
39
+ worst = max(worst, 2)
40
+ except Exception as e: # one bad target must not kill the run
41
+ print(json.dumps({"url": url, "error": str(e)}))
42
+ worst = max(worst, 2)
43
+ return worst
44
+
45
+
46
+ def cli():
47
+ """Console-script entry point (the `phpscan` command)."""
48
+ return main(sys.argv[1:])
49
+
50
+
51
+ if __name__ == "__main__":
52
+ sys.exit(main(sys.argv[1:]))
phpscan/data/cves.json ADDED
@@ -0,0 +1 @@
1
+ {"advisories":[{"affected_sapis":["apache2handler (mod_php)","php-fpm","php-cgi","frankenphp","litespeed"],"cves":["CVE-2026-109618","CVE-2026-109619"],"cvss_v4":9.2,"distro_note":"Distribution packages may backport the fix without changing the upstream version string — verify via the package changelog or distro advisory, not the version alone. Third-party repositories lagged the fix on release day (Remi had no fixed build on 2026-10-21): when the repo has no fixed package, use the official container image or a source build, or apply the interim mitigations.","eol_below":"8.2","eol_note":"Branches below 8.2 receive no security fixes from the PHP project. There is NO patched release for them: the only remedies are a branch upgrade or taking the site offline (CERT-EU).","fixed_releases":{"8.2":"8.2.35","8.3":"8.3.36","8.4":"8.4.27","8.5":"8.5.12"},"id":"php2shell-2026-10","interim_mitigations":["Restrict access to the site (IP allow-list at the proxy / CDN) until patched — reduces exposure; it is not a fix","Enforce request-size limits at the reverse proxy / CDN edge as defense-in-depth","If no patched release can be deployed, take the site offline (CERT-EU guidance for unpatchable sites)"],"published":"2026-10-21","references":["https://www.php.net/releases/", "https://cert.europa.eu/publications/security-advisories/2026-018/", "https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching", "https://www.cve.org/CVERecord?id=CVE-2026-109618", "https://www.cve.org/CVERecord?id=CVE-2026-109619"],"summary":"Two critical flaws in PHP core: CVE-2026-109618 (remote code execution via out-of-memory corruption) and CVE-2026-109619 (remote code execution via stale global state). Both are exploitable over the network with no authentication. Any PHP application on a vulnerable build is exposed, regardless of the application itself.","timeline":"Vulnerability details leaked 2026-10-20. Fixed releases published 2026-10-21 (14:00 UTC). Exploitation was observed in the wild from 2026-10-21 07:43 UTC (Cloudflare data, reported by Sansec) — the exposure window opens at the leak, not at the patch.","title":"php2shell — unauthenticated remote code execution in PHP core"}],"source_notes":"Fixed releases published by the PHP project on 2026-10-21 (~14:00 UTC). CERT-EU advisory 2026-018, a CISA alert and an FBI alert circulated the same day; CERT-EU advised operators who cannot patch to take affected sites offline.","updated":"2026-10-22"}
phpscan/scan.py ADDED
@@ -0,0 +1,467 @@
1
+ """Step 1a — external scan (no credentials).
2
+
3
+ Fingerprints PHP from what a site discloses over HTTP: X-Powered-By /
4
+ Server headers, PHPSESSID cookies, CMS generator signals, and exposed
5
+ phpinfo pages. An exact version, when disclosed, is checked against the
6
+ patched-release floors.
7
+
8
+ Honest limit: most well-run sites suppress version headers. For those the
9
+ scan reports "PHP detected, version hidden — patch status cannot be
10
+ confirmed from outside" and recommends the credentialed deep check.
11
+ """
12
+
13
+ import http.client
14
+ import ipaddress
15
+ import re
16
+ import socket
17
+ import ssl
18
+ import threading
19
+ import time
20
+ import urllib.parse
21
+
22
+ from . import versioning
23
+
24
+ _UA = "phpscan/1.4.4 (+https://phpatch.com/security)"
25
+ _TIMEOUT = 12
26
+ _MAX_BODY = 512 * 1024
27
+ _MAX_REDIRECTS = 5
28
+ _PHPINFO_PATHS = ("/phpinfo.php", "/info.php", "/php-info.php", "/test.php")
29
+
30
+
31
+ class BlockedTarget(Exception):
32
+ """Target refused by the scanner's SSRF/abuse policy."""
33
+
34
+
35
+ # Ranges that Python's is_global / is_private flags do not classify
36
+ # consistently across versions, checked explicitly: carrier-grade
37
+ # NAT (includes cloud metadata endpoints in that space), the IETF
38
+ # special-purpose /24, benchmarking space, the deprecated 6to4
39
+ # relay anycast, and the IPv6 transition prefixes whose embedded
40
+ # IPv4 destination could be non-public (NAT64 well-known prefix,
41
+ # Teredo).
42
+ _EXTRA_BLOCKED = [ipaddress.ip_network(n) for n in (
43
+ "100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15",
44
+ "192.88.99.0/24", "64:ff9b::/96", "2001::/32")]
45
+
46
+
47
+ def _blocked(ip):
48
+ """True unless the address is globally routable public space.
49
+
50
+ Primary test is is_global (negated): it covers loopback,
51
+ RFC1918, link-local (incl. cloud metadata 169.254.169.254),
52
+ CGNAT, reserved and unspecified space across Python versions,
53
+ which the individual is_private-style flags do not.
54
+ """
55
+ return (not ip.is_global or ip.is_multicast
56
+ or any(ip in n for n in _EXTRA_BLOCKED))
57
+
58
+
59
+ def _public_ips(host):
60
+ """Resolve a host and require EVERY answer to be a public IP.
61
+
62
+ Blocks loopback, private, link-local (incl. cloud metadata
63
+ 169.254.169.254), reserved, multicast and unspecified addresses,
64
+ so the public scanner can never be pointed at internal services.
65
+ """
66
+ try:
67
+ infos = socket.getaddrinfo(host, None,
68
+ proto=socket.IPPROTO_TCP)
69
+ except socket.gaierror as e:
70
+ raise BlockedTarget(f"DNS lookup failed for {host}: {e}")
71
+ ips = []
72
+ for info in infos:
73
+ ip = ipaddress.ip_address(info[4][0])
74
+ # Unwrap transition forms: the real TCP destination of an
75
+ # IPv4-mapped (::ffff:a.b.c.d) or 6to4 (2002:xxxx::) address
76
+ # is the embedded IPv4, so THAT is what must be checked —
77
+ # the IPv6 spelling alone does not trip the IPv4 ranges.
78
+ if isinstance(ip, ipaddress.IPv6Address):
79
+ if ip.ipv4_mapped is not None:
80
+ ip = ip.ipv4_mapped
81
+ elif ip.sixtofour is not None:
82
+ ip = ip.sixtofour
83
+ if _blocked(ip):
84
+ raise BlockedTarget(
85
+ f"{host} resolves to a non-public address; "
86
+ "the scanner only fetches public websites")
87
+ if str(ip) not in ips:
88
+ ips.append(str(ip))
89
+ if not ips:
90
+ raise BlockedTarget(f"DNS lookup returned nothing for {host}")
91
+ return ips
92
+
93
+
94
+ class _PinnedHTTPS(http.client.HTTPSConnection):
95
+ """HTTPS to a pinned IP, with SNI + certificate checks against
96
+ the original hostname (defeats DNS-rebinding between the policy
97
+ check and the connect)."""
98
+
99
+ def __init__(self, ip, port, sni_host, timeout):
100
+ super().__init__(ip, port, timeout=timeout,
101
+ context=ssl.create_default_context())
102
+ self._sni_host = sni_host
103
+
104
+ def connect(self):
105
+ http.client.HTTPConnection.connect(self)
106
+ self.sock = self._context.wrap_socket(
107
+ self.sock, server_hostname=self._sni_host)
108
+
109
+
110
+ def _literal_refusal(host):
111
+ """Refuse non-standard IP encodings: decimal (2130706433), hex
112
+ (0x7f000001), octal parts (0177.0.0.1), short forms (127.1).
113
+ Resolver libraries disagree on these spellings, so they never
114
+ reach a resolver at all."""
115
+ if re.fullmatch(r"0[xX][0-9a-fA-F]+", host):
116
+ return "hex-encoded IP addresses are refused"
117
+ if re.fullmatch(r"[0-9.]+", host):
118
+ parts = host.split(".")
119
+ if len(parts) != 4 or any(
120
+ not p.isdigit() or int(p) > 255 or
121
+ (len(p) > 1 and p.startswith("0")) for p in parts):
122
+ return "non-standard IP address forms are refused"
123
+ return None
124
+
125
+
126
+ def _preflight(url):
127
+ """Validate one fetch target. Called for the initial URL and
128
+ for EVERY redirect hop, so a redirect cannot smuggle in a
129
+ blocked scheme, port, credential or encoded address.
130
+ Returns (error, parts, port); error is None when clean."""
131
+ parts = urllib.parse.urlsplit(url)
132
+ if parts.scheme not in ("http", "https"):
133
+ return "only http/https URLs are scanned", None, None
134
+ if parts.username or parts.password:
135
+ return "URLs with credentials are refused", None, None
136
+ host = parts.hostname
137
+ if not host:
138
+ return "URL has no host", None, None
139
+ refusal = _literal_refusal(host)
140
+ if refusal:
141
+ return refusal, None, None
142
+ try:
143
+ port = parts.port or (443 if parts.scheme == "https" else 80)
144
+ except ValueError:
145
+ port = -1
146
+ if port not in (80, 443):
147
+ return "only ports 80 and 443 are scanned", None, None
148
+ return None, parts, port
149
+
150
+
151
+ _DEADLINE = 25 # total seconds allowed for one response body
152
+ _TOTAL_DEADLINE = 40 # total seconds for one whole SCAN: the
153
+ # homepage fetch and every probe share this single budget
154
+
155
+
156
+ def _kill_conn(conn):
157
+ """Watchdog action at the total deadline: shut the socket down
158
+ so any blocked read (header or body) fails at once. A per-read
159
+ socket timeout alone cannot bound a slow drip — every byte
160
+ that arrives inside the timeout re-arms the wait — so the
161
+ deadline is enforced by killing the connection, not by hoping
162
+ the reads notice the clock."""
163
+ try:
164
+ sock = getattr(conn, "sock", None)
165
+ if sock is not None:
166
+ try:
167
+ sock.shutdown(socket.SHUT_RDWR)
168
+ except Exception:
169
+ pass
170
+ try:
171
+ sock.close()
172
+ except Exception:
173
+ pass
174
+ except Exception:
175
+ pass
176
+
177
+
178
+ def _read_capped(resp, budget=None, sock=None):
179
+ """Read a response body up to _MAX_BODY bytes under a TOTAL
180
+ deadline, so a slow-drip server cannot hold the scanner open
181
+ one byte at a time under the per-read socket timeout. The cap
182
+ applies to the raw bytes as received; the scanner never
183
+ decompresses, so a compressed bomb never expands. `budget`
184
+ (seconds remaining in the fetch's total deadline) can only
185
+ shorten the read, never extend it past _DEADLINE. When `sock`
186
+ is given, its timeout is recomputed from the remaining budget
187
+ before every chunk read, so no single read can wait past the
188
+ deadline either."""
189
+ chunks, got = [], 0
190
+ secs = _DEADLINE if budget is None else min(_DEADLINE, budget)
191
+ end = time.monotonic() + secs
192
+ while got <= _MAX_BODY:
193
+ remaining = end - time.monotonic()
194
+ if remaining <= 0:
195
+ break
196
+ if sock is not None:
197
+ try:
198
+ sock.settimeout(max(0.1, min(_TIMEOUT, remaining)))
199
+ except Exception:
200
+ pass
201
+ try:
202
+ chunk = resp.read(min(65536, _MAX_BODY + 1 - got))
203
+ except http.client.IncompleteRead as e:
204
+ # Server/proxy closed mid-body (common behind CDNs);
205
+ # the partial body is still a successful fetch.
206
+ if e.partial:
207
+ chunks.append(e.partial)
208
+ break
209
+ if not chunk:
210
+ break
211
+ chunks.append(chunk)
212
+ got += len(chunk)
213
+ return b"".join(chunks)[:_MAX_BODY]
214
+
215
+
216
+ def _headers_dict(resp):
217
+ """Response headers as a dict, merging duplicates (notably
218
+ Set-Cookie) instead of letting dict() keep only the last one —
219
+ a PHPSESSID on an earlier Set-Cookie line must still be seen."""
220
+ out = {}
221
+ for k, v in resp.getheaders():
222
+ out[k] = f"{out[k]}, {v}" if k in out else v
223
+ return out
224
+
225
+
226
+ def _fetch(url, ctx=None, deadline=None):
227
+ """Guarded fetch: http/https only, ports 80/443 only, no URL
228
+ credentials, every redirect hop re-validated and IP-pinned,
229
+ body capped, and a TOTAL deadline across connect + headers +
230
+ body + all hops, so a slow-drip server cannot hold a worker
231
+ open. `deadline` (a time.monotonic() timestamp) lets the
232
+ caller share ONE budget across a whole scan — homepage plus
233
+ all probes — instead of granting each fetch its own; without
234
+ it, the fetch gets _TOTAL_DEADLINE to itself. The deadline is
235
+ enforced by a watchdog that kills the socket at the deadline:
236
+ per-read socket timeouts alone cannot stop a drip, because
237
+ each byte that arrives inside the timeout re-arms the wait.
238
+ When a host resolves to several public IPs, each is tried in
239
+ turn on connection-level failures. Returns the same dict
240
+ shape as before."""
241
+ current = url
242
+ end = deadline if deadline is not None else \
243
+ time.monotonic() + _TOTAL_DEADLINE
244
+ for _hop in range(_MAX_REDIRECTS + 1):
245
+ err, parts, port = _preflight(current)
246
+ if err:
247
+ return {"ok": False, "status": None, "final_url": current,
248
+ "headers": {}, "body": "", "error": err}
249
+ host = parts.hostname
250
+ try:
251
+ ips = _public_ips(host)
252
+ except BlockedTarget as e:
253
+ return {"ok": False, "status": None, "final_url": current,
254
+ "headers": {}, "body": "", "error": str(e)}
255
+ path = parts.path or "/"
256
+ if parts.query:
257
+ path += "?" + parts.query
258
+ host_hdr = host if port in (80, 443) and \
259
+ parts.port is None else f"{host}:{port}"
260
+ last_err = None
261
+ redirected = False
262
+ for ip in ips:
263
+ remaining = end - time.monotonic()
264
+ if remaining <= 0:
265
+ return {"ok": False, "status": None,
266
+ "final_url": current, "headers": {}, "body": "",
267
+ "error": "scan timed out: total deadline "
268
+ f"({_TOTAL_DEADLINE}s) exceeded"}
269
+ timeout = max(1.0, min(_TIMEOUT, remaining))
270
+ conn = None
271
+ watchdog = None
272
+ try:
273
+ if parts.scheme == "https":
274
+ conn = _PinnedHTTPS(ip, port, host, timeout)
275
+ else:
276
+ conn = http.client.HTTPConnection(ip, port,
277
+ timeout=timeout)
278
+ conn.request("GET", path,
279
+ headers={"User-Agent": _UA,
280
+ "Host": host_hdr,
281
+ "Accept": "*/*",
282
+ "Connection": "close"})
283
+ if getattr(conn, "sock", None) is not None:
284
+ watchdog = threading.Timer(
285
+ max(0.0, end - time.monotonic()),
286
+ _kill_conn, args=(conn,))
287
+ watchdog.daemon = True
288
+ watchdog.start()
289
+ resp = conn.getresponse()
290
+ status = resp.status
291
+ headers = _headers_dict(resp)
292
+ location = None
293
+ for k, v in headers.items():
294
+ if k.lower() == "location":
295
+ location = v
296
+ if status in (301, 302, 303, 307, 308) and location:
297
+ current = urllib.parse.urljoin(current, location)
298
+ redirected = True
299
+ break
300
+ raw = _read_capped(resp, end - time.monotonic(),
301
+ sock=getattr(conn, "sock", None))
302
+ body = raw.decode("utf-8", "replace")
303
+ return {"ok": status < 400, "status": status,
304
+ "final_url": current, "headers": headers,
305
+ "body": body,
306
+ "error": None if status < 400
307
+ else f"HTTP {status}"}
308
+ except Exception as e: # DNS, TLS, timeout, refused
309
+ last_err = e
310
+ finally:
311
+ if watchdog is not None:
312
+ watchdog.cancel()
313
+ if conn is not None:
314
+ try:
315
+ conn.close()
316
+ except Exception:
317
+ pass
318
+ if time.monotonic() >= end:
319
+ return {"ok": False, "status": None,
320
+ "final_url": current, "headers": {}, "body": "",
321
+ "error": "scan timed out: total deadline "
322
+ f"({_TOTAL_DEADLINE}s) exceeded"}
323
+ if redirected:
324
+ continue
325
+ return {"ok": False, "status": None, "final_url": current,
326
+ "headers": {}, "body": "", "error": str(last_err)}
327
+ return {"ok": False, "status": None, "final_url": current,
328
+ "headers": {}, "body": "",
329
+ "error": f"too many redirects (>{_MAX_REDIRECTS})"}
330
+
331
+
332
+
333
+ def _h(headers, name):
334
+ for k, v in headers.items():
335
+ if k.lower() == name.lower():
336
+ return v
337
+ return ""
338
+
339
+
340
+ def scan(url):
341
+ if not re.match(r"^https?://", url):
342
+ url = "https://" + url
343
+ # One deadline for the WHOLE scan: the homepage fetch and all
344
+ # four phpinfo probes draw from the same _TOTAL_DEADLINE
345
+ # budget, so a scan can never run ~5x the deadline.
346
+ end = time.monotonic() + _TOTAL_DEADLINE
347
+ home = _fetch(url, deadline=end)
348
+ result = {"target": url, "reachable": home["ok"],
349
+ "http_status": home["status"], "final_url": home["final_url"],
350
+ "error": home.get("error"), "signals": {},
351
+ "php_version": None, "version_source": None,
352
+ "cms": [], "phpinfo_exposed": False, "verdict": None}
353
+ if not home["ok"] and home["status"] is None:
354
+ result["verdict"] = {"status": "unreachable",
355
+ "detail": f"Site did not answer: "
356
+ f"{home.get('error')}"}
357
+ return result
358
+
359
+ headers, body = home["headers"], home["body"]
360
+ xpb = _h(headers, "X-Powered-By")
361
+ server = _h(headers, "Server")
362
+ cookies = _h(headers, "Set-Cookie")
363
+ gen = _h(headers, "X-Generator")
364
+ if xpb:
365
+ result["signals"]["x_powered_by"] = xpb
366
+ if server:
367
+ result["signals"]["server"] = server
368
+ if "PHPSESSID" in cookies:
369
+ result["signals"]["phpsessid_cookie"] = True
370
+ if gen:
371
+ result["signals"]["x_generator"] = gen
372
+
373
+ # CMS fingerprints
374
+ m = re.search(r'<meta[^>]+name=["\']generator["\'][^>]+'
375
+ r'content=["\']([^"\']+)', body, re.I)
376
+ meta_gen = m.group(1) if m else ""
377
+ low = body.lower()
378
+ if "wp-content" in low or "wp-includes" in low or \
379
+ "wordpress" in meta_gen.lower():
380
+ result["cms"].append("WordPress")
381
+ if "powered by phpbb" in low or "styles/prosilver" in low:
382
+ result["cms"].append("phpBB")
383
+ if "joomla" in meta_gen.lower() or "/media/jui/" in low:
384
+ result["cms"].append("Joomla")
385
+ if "drupal" in gen.lower() or "/core/modules/" in low:
386
+ result["cms"].append("Drupal")
387
+ if meta_gen:
388
+ result["signals"]["meta_generator"] = meta_gen
389
+
390
+ # Exact version disclosure: headers first
391
+ raw_version_text = ""
392
+ for src, text in (("X-Powered-By", xpb), ("Server", server)):
393
+ v = versioning.parse_version(re.search(r"PHP/(\S+)", text).group(1)) \
394
+ if re.search(r"PHP/(\S+)", text) else None
395
+ if v:
396
+ result["php_version"] = versioning.fmt(v)
397
+ result["version_source"] = src
398
+ raw_version_text = re.search(r"PHP/(\S+)", text).group(1)
399
+ break
400
+
401
+ # phpinfo probes — an exposed phpinfo both leaks the exact version
402
+ # and is itself a finding, so the probes run on every scan (the
403
+ # footprint is the four well-known paths, nothing more). Probes
404
+ # are built from the final URL's ORIGIN only: appending a probe
405
+ # path to a deep final URL (…/en/index.php?x=1) would request a
406
+ # nonsense URL and miss a real exposed phpinfo at the root.
407
+ _fp = urllib.parse.urlsplit(home["final_url"])
408
+ base = f"{_fp.scheme}://{_fp.netloc}"
409
+ for path in _PHPINFO_PATHS:
410
+ probe = _fetch(base + path, deadline=end)
411
+ b = probe["body"]
412
+ if probe["status"] == 200 and "phpinfo()" in b and \
413
+ re.search(r"PHP Version\s*&?#?\w*\s*([0-9]+\.[0-9]+\.[0-9]+)", b):
414
+ vm = re.search(r"PHP Version[^0-9]*([0-9]+\.[0-9]+\.[0-9]+)", b)
415
+ result["phpinfo_exposed"] = True
416
+ result["signals"]["phpinfo_path"] = path
417
+ if vm and result["php_version"] is None:
418
+ result["php_version"] = vm.group(1)
419
+ result["version_source"] = f"phpinfo ({path})"
420
+ raw_version_text = vm.group(0)
421
+ break
422
+
423
+ vtuple = versioning.parse_version(result["php_version"])
424
+ if vtuple:
425
+ result["verdict"] = versioning.verdict_for(
426
+ vtuple,
427
+ distro=versioning.distro_build(raw_version_text))
428
+ elif result["signals"] or result["cms"]:
429
+ result["verdict"] = {
430
+ "status": "indeterminate",
431
+ "detail": "PHP is present (fingerprinted) but the version is "
432
+ "not disclosed, so the patch status cannot be "
433
+ "confirmed from outside. Run the deep check (SSH, "
434
+ "read-only) for a definitive verdict."}
435
+ else:
436
+ result["verdict"] = {
437
+ "status": "no_php_detected",
438
+ "detail": "No PHP signals found. The site may not run PHP, or "
439
+ "may hide every fingerprint."}
440
+ return result
441
+
442
+
443
+ def render_text(r):
444
+ lines = [f"Target: {r['target']}",
445
+ f"Reachable: {r['reachable']} (HTTP {r['http_status']})",
446
+ f"Final URL: {r['final_url']}"]
447
+ if r["cms"]:
448
+ lines.append(f"CMS: {', '.join(r['cms'])}")
449
+ if r["php_version"]:
450
+ lines.append(f"PHP version: {r['php_version']} "
451
+ f"(via {r['version_source']})")
452
+ if r["phpinfo_exposed"]:
453
+ lines.append("!! phpinfo page is publicly exposed — remove it.")
454
+ v = r["verdict"]
455
+ lines.append(f"VERDICT: {v['status'].upper()}")
456
+ lines.append(f" {v['detail']}")
457
+ if v["status"] == "indeterminate":
458
+ lines.append("NEXT: Most well-run sites hide their PHP "
459
+ "version, so an outside scan cannot be certain. "
460
+ "For a certain answer, run the read-only deep "
461
+ "check on the server (it only reads versions and "
462
+ "changes nothing):")
463
+ lines.append(" phppatch deepcheck --host "
464
+ "root@your-server")
465
+ lines.append(" Site ownership is verified before "
466
+ "any deeper check. This scan used passive fingerprinting and limited, read-only path checks only — no exploit probes.")
467
+ return "\n".join(lines)
phpscan/versioning.py ADDED
@@ -0,0 +1,152 @@
1
+ """Version parsing and vulnerability verdicts.
2
+
3
+ A PHP build is judged against every advisory in data/cves.json. For each
4
+ advisory, supported branches have a fixed-release floor; anything older
5
+ is vulnerable, and any branch below the advisory's EOL line has no fix at
6
+ all (branch upgrade or offline are the only remedies).
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+
13
+ _DATA = os.path.join(os.path.dirname(__file__), "data", "cves.json")
14
+ _VERSION_RE = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?")
15
+
16
+
17
+ def load_advisories():
18
+ with open(_DATA, encoding="utf-8") as f:
19
+ return json.load(f)["advisories"]
20
+
21
+
22
+ def parse_version(text):
23
+ """Extract the first x.y[.z] version from arbitrary text -> tuple|None."""
24
+ if not text:
25
+ return None
26
+ m = _VERSION_RE.search(str(text))
27
+ if not m:
28
+ return None
29
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
30
+
31
+
32
+ def fmt(version):
33
+ return ".".join(str(p) for p in version)
34
+
35
+
36
+ def branch_of(version):
37
+ return f"{version[0]}.{version[1]}"
38
+
39
+
40
+ _DISTRO_RE = re.compile(r"(ubuntu|~deb\d|\+deb\d|-deb\d|\.deb\d|debian)",
41
+ re.I)
42
+
43
+
44
+ def distro_build(text):
45
+ """True when a raw version string looks like a distribution
46
+ package build (Ubuntu/Debian-style suffix). Distros backport
47
+ security fixes without moving the upstream version, so a
48
+ below-floor distro build is a 'check' signal, not proof."""
49
+ return bool(text and _DISTRO_RE.search(str(text)))
50
+
51
+
52
+ def verdict_for(version, distro=False):
53
+ """Return a verdict dict for one PHP version tuple.
54
+
55
+ status: 'patched' | 'vulnerable' | 'possibly_vulnerable' |
56
+ 'eol' | 'not_assessed' | 'unknown'
57
+ patched — at or above every advisory floor for its branch
58
+ vulnerable — a patched release exists for its branch; it is below it
59
+ possibly_vulnerable — same, but the build looks like a distro
60
+ package (backports may already carry the fix)
61
+ eol — branch is below an advisory's EOL line: no fix exists
62
+ not_assessed — branch is newer than the advisory database
63
+ unknown — version could not be determined
64
+ """
65
+ if version is None:
66
+ return {"status": "unknown", "version": None, "branch": None,
67
+ "detail": "PHP version could not be determined."}
68
+ branch = branch_of(version)
69
+ problems = []
70
+ assessed = False
71
+ for adv in load_advisories():
72
+ eol_below = parse_version(adv["eol_below"])
73
+ if (version[0], version[1]) < (eol_below[0], eol_below[1]):
74
+ detail = (f"Branch {branch} is end-of-life for "
75
+ f"{adv['id']}: no patched release exists. "
76
+ "The php2shell vector can be only partially "
77
+ "mitigated at best by configuration "
78
+ "(session.upload_progress.enabled = Off); the "
79
+ "only complete remedies are a branch upgrade "
80
+ "or taking the site offline.")
81
+ if distro:
82
+ detail += (" This build looks like a distribution "
83
+ "package: the distro may still backport "
84
+ "fixes for this branch, so confirm via "
85
+ "the distro security tracker or the "
86
+ "on-server deep check.")
87
+ return {"status": "eol", "version": fmt(version),
88
+ "branch": branch, "advisory": adv["id"],
89
+ "cves": adv["cves"], "detail": detail,
90
+ "remediation": ("Upgrade to a supported PHP branch "
91
+ "(8.2 or later) and apply its patched "
92
+ "release, or take the site offline.")}
93
+ floor_txt = adv["fixed_releases"].get(branch)
94
+ if floor_txt is None:
95
+ continue # branch newer than the advisory knows
96
+ assessed = True
97
+ floor = parse_version(floor_txt)
98
+ if version < floor:
99
+ problems.append({"advisory": adv["id"], "cves": adv["cves"],
100
+ "cvss_v4": adv.get("cvss_v4"),
101
+ "floor": floor_txt})
102
+ if problems:
103
+ p = problems[0]
104
+ detail = (f"PHP {fmt(version)} is below the patched "
105
+ f"release {p['floor']} for branch {branch} "
106
+ f"({', '.join(p['cves'])}).")
107
+ if distro:
108
+ detail += (" This build looks like a distribution "
109
+ "package: distros backport security fixes "
110
+ "without changing the upstream version, so "
111
+ "treat this as a check signal, not proof — "
112
+ "confirm via the distro security tracker or "
113
+ "the on-server deep check.")
114
+ remediation = (f"Upgrade PHP {branch} to {p['floor']} or later.")
115
+ if distro:
116
+ remediation += (" Confirm via the distro security tracker "
117
+ "that a backported fix is installed — a "
118
+ "distribution package may already be patched.")
119
+ return {"status": "possibly_vulnerable" if distro
120
+ else "vulnerable",
121
+ "version": fmt(version),
122
+ "branch": branch, "advisory": p["advisory"],
123
+ "cves": p["cves"], "cvss_v4": p.get("cvss_v4"),
124
+ "floor": p["floor"], "detail": detail,
125
+ "remediation": remediation}
126
+ if not assessed:
127
+ return {"status": "not_assessed", "version": fmt(version),
128
+ "branch": branch,
129
+ "detail": f"PHP {fmt(version)} is on branch {branch}, "
130
+ "which is newer than this scanner's "
131
+ "advisory database, so its patch status "
132
+ "was not assessed. Update the advisory "
133
+ "data and scan again."}
134
+ return {"status": "patched", "version": fmt(version), "branch": branch,
135
+ "detail": f"PHP {fmt(version)} is at or above every patched "
136
+ "release floor for its branch. 'Patched' covers "
137
+ "only the advisories in this database — it is "
138
+ "not a general security guarantee."}
139
+
140
+
141
+ def fixed_tag_for(version, variant):
142
+ """Official docker php image tag for the patched release of a branch.
143
+
144
+ e.g. version (8,3,35), variant 'apache' -> 'php:8.3.36-apache'
145
+ Returns None when no advisory floor applies.
146
+ """
147
+ v = verdict_for(version)
148
+ if v["status"] in ("vulnerable", "possibly_vulnerable"):
149
+ return f"php:{v['floor']}-{variant}"
150
+ if v["status"] == "patched":
151
+ return f"php:{fmt(version)}-{variant}"
152
+ return None
@@ -0,0 +1,294 @@
1
+ Metadata-Version: 2.4
2
+ Name: phpscan
3
+ Version: 1.4.4
4
+ Summary: Free external PHP vulnerability scanner (php2shell / CVE-2026-109618, CVE-2026-109619) — passive fingerprinting plus limited, read-only path checks. No exploit probes.
5
+ Author: Superstar Telecom LLC
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # phpscan — free open-source PHP vulnerability scanner
13
+
14
+ **Is your PHP version vulnerable to php2shell?** phpscan is a
15
+ free, disclosure-only scanner that answers that question for any
16
+ public website. It checks the PHP version a site discloses against
17
+ the fixed releases for **php2shell — CVE-2026-109618 /
18
+ CVE-2026-109619** (CVSS v4 9.2, unauthenticated remote code
19
+ execution in PHP core) — and, more generally, against the branch
20
+ floors in its advisory database.
21
+
22
+ It is the open-source scanner behind [phpatch.com](https://phpatch.com).
23
+
24
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
25
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/)
26
+ [![Dependencies: zero](https://img.shields.io/badge/dependencies-zero-brightgreen.svg)](#what-it-does)
27
+ [![Tests](https://github.com/PHPatch/phpscan/actions/workflows/tests.yml/badge.svg)](https://github.com/PHPatch/phpscan/actions/workflows/tests.yml)
28
+
29
+ ## What it does
30
+
31
+ - **Disclosure-only.** It reads what a site already discloses
32
+ publicly — `X-Powered-By` / `Server` headers, `PHPSESSID` cookies,
33
+ generator tags — plus a limited check for a publicly exposed
34
+ phpinfo page at a few well-known paths. It never sends an exploit
35
+ probe, a crafted payload, or anything designed to trigger the bug.
36
+ - **Honest verdicts.** If a site hides its version, the scanner
37
+ says so (VERSION HIDDEN) instead of guessing. If a build looks
38
+ like a distribution package, a below-floor version is reported
39
+ as a check signal (POSSIBLY VULNERABLE), because distros backport
40
+ fixes without changing the version string.
41
+ - **Public targets only.** The fetcher refuses loopback, private
42
+ and link-local addresses (including cloud metadata endpoints),
43
+ non-HTTP(S) schemes and ports other than 80/443, pins the
44
+ validated IP, and re-validates every redirect — it is designed
45
+ so it cannot be pointed at internal services. The refusals are
46
+ exercised by the test suite in `tests/` on every push.
47
+ - **Zero dependencies.** Python 3.11+ standard library only.
48
+
49
+ ## Verdicts
50
+
51
+ Every scan returns one JSON object per URL. The `verdict.status`
52
+ field uses the vocabulary below (shown here in its display form).
53
+ Actionable verdicts (`vulnerable`, `possibly_vulnerable`, `eol`)
54
+ also carry a `remediation` field naming the upgrade that fixes
55
+ them; patched verdicts carry none.
56
+
57
+ | Verdict | JSON `status` | Meaning |
58
+ |---|---|---|
59
+ | **VULNERABLE** | `vulnerable` | The disclosed version is below the fixed release for its branch, and a patched release exists. |
60
+ | **POSSIBLY VULNERABLE** | `possibly_vulnerable` | Below the floor, but the build looks like a distribution package — distros backport fixes, so this is a check signal, not proof. |
61
+ | **PATCHED** | `patched` | The disclosed version is at or above every advisory floor for its branch. (Not a general security guarantee — only "not below the fixed releases in the database.") |
62
+ | **END OF LIFE** | `eol` | The branch is below the advisory's EOL line (< 8.2 for php2shell): no patched release exists. The only complete remedies are a branch upgrade or taking the site offline. |
63
+ | **VERSION HIDDEN** | `indeterminate` | PHP is present but the version is not disclosed, so patch status cannot be confirmed from outside. Most well-run sites land here. |
64
+ | **NOT ASSESSED** | `not_assessed` | The branch is newer than the advisory database, so the scanner cannot judge it. |
65
+
66
+ Two more statuses cover the scan itself: `no_php_detected` (no PHP
67
+ signals found) and `unreachable` (the site did not answer, or the
68
+ target was refused by the safety policy).
69
+
70
+ ## Sample session
71
+
72
+ Recorded against a local test fixture that discloses
73
+ `X-Powered-By: PHP/8.3.20` — the output format is exactly what the
74
+ tool prints:
75
+
76
+ ```console
77
+ $ python3 -m phpscan http://fixture.example
78
+ {
79
+ "target": "http://fixture.example",
80
+ "reachable": true,
81
+ "http_status": 200,
82
+ "final_url": "http://fixture.example",
83
+ "error": null,
84
+ "signals": {
85
+ "x_powered_by": "PHP/8.3.20",
86
+ "server": "Apache/2.4.62"
87
+ },
88
+ "php_version": "8.3.20",
89
+ "version_source": "X-Powered-By",
90
+ "cms": [],
91
+ "phpinfo_exposed": false,
92
+ "verdict": {
93
+ "status": "vulnerable",
94
+ "version": "8.3.20",
95
+ "branch": "8.3",
96
+ "advisory": "php2shell-2026-10",
97
+ "cves": [
98
+ "CVE-2026-109618",
99
+ "CVE-2026-109619"
100
+ ],
101
+ "cvss_v4": 9.2,
102
+ "floor": "8.3.36",
103
+ "detail": "PHP 8.3.20 is below the patched release 8.3.36 for branch 8.3 (CVE-2026-109618, CVE-2026-109619)."
104
+ }
105
+ }
106
+ $ echo $?
107
+ 1
108
+ ```
109
+
110
+ Exit code 1 = an actionable finding. A site that runs PHP but
111
+ hides its version returns the VERSION HIDDEN verdict instead
112
+ (verdict block shown; the rest of the object lists the signals it
113
+ did see):
114
+
115
+ ```json
116
+ "verdict": {
117
+ "status": "indeterminate",
118
+ "detail": "PHP is present (fingerprinted) but the version is not disclosed, so the patch status cannot be confirmed from outside. Run the deep check (SSH, read-only) for a definitive verdict."
119
+ }
120
+ ```
121
+
122
+ ## Branch floors (php2shell)
123
+
124
+ A branch is PATCHED at or above its fixed release; anything below
125
+ it is VULNERABLE (or POSSIBLY VULNERABLE for distro builds):
126
+
127
+ | PHP branch | Fixed in |
128
+ |---|---|
129
+ | 8.2 | **8.2.35** |
130
+ | 8.3 | **8.3.36** |
131
+ | 8.4 | **8.4.27** |
132
+ | 8.5 | **8.5.12** |
133
+ | < 8.2 | No fix — **END OF LIFE** (branch upgrade or offline) |
134
+
135
+ The floors live in `phpscan/data/cves.json`, reviewed against
136
+ the php.net release announcements on every PHP security release
137
+ (see [Advisory data](#advisory-data)).
138
+
139
+ ## Install
140
+
141
+ ```bash
142
+ git clone https://github.com/PHPatch/phpscan.git
143
+ cd phpscan
144
+ # Python 3.11+ standard library only — no pip install, no dependencies.
145
+ ```
146
+
147
+ Or install it directly from GitHub, no clone needed:
148
+
149
+ ```bash
150
+ pip install git+https://github.com/PHPatch/phpscan.git
151
+ python3 -m phpscan https://your-site.com
152
+ ```
153
+
154
+ ## Run it
155
+
156
+ From the clone, run it right there — no install step:
157
+
158
+ ```bash
159
+ python3 -m phpscan https://your-site.com
160
+ ```
161
+
162
+ Prefer a zip? Download `phpscan.zip` from phpatch.com, then
163
+ either install it and run from anywhere:
164
+
165
+ ```bash
166
+ python3 -m pip install phpscan.zip
167
+ python3 -m phpscan https://your-site.com
168
+ ```
169
+
170
+ or unzip and run in place:
171
+
172
+ ```bash
173
+ unzip phpscan.zip
174
+ cd phpscan
175
+ python3 -m phpscan https://your-site.com
176
+ ```
177
+
178
+ Output is one JSON object per URL: the verdict, the disclosed PHP
179
+ version and where it was disclosed, and CMS fingerprints.
180
+
181
+ Exit codes (for scripts and CI): **0** — scanned, no actionable
182
+ finding; **1** — at least one target is `vulnerable`,
183
+ `possibly_vulnerable`, or on an end-of-life branch; **2** — usage
184
+ error, or a target could not be judged at all: unreachable, or
185
+ `not_assessed` (its PHP branch is newer than the advisory
186
+ database, so the scanner cannot judge it). With several targets,
187
+ the worst code wins.
188
+
189
+ Prefer a browser? The same scan runs free at
190
+ [phpatch.com](https://phpatch.com) and includes a PDF report.
191
+
192
+ ## How it compares
193
+
194
+ The php2shell scanners that appeared in October 2026 differ
195
+ mostly in **how they decide you are vulnerable** — and in whether
196
+ they try to trigger the bug to find out:
197
+
198
+ | Tool | Detection | Sends exploit triggers? | Remediation | License |
199
+ |---|---|---|---|---|
200
+ | **phpscan** (this repo) | Version fingerprint from what the site discloses; says "version hidden" instead of guessing | **No — never** | Via [PHP Patch Assurance](https://phpatch.com): snapshot-first patch, verify, rollback, evidence report | MIT |
201
+ | OpenAI php2shell-checker | Version fingerprint from `X-Powered-By` / `Server` headers only | No | None | Apache-2.0 |
202
+ | Searchlight Cyber php2shell-scanner | Behavioral — crafted multipart probes force PHP out of memory and watch for the crash | **Yes, deliberately** | None | No LICENSE file |
203
+ | SamJUK php2shell-check | Behavioral, local — observes how the installed PHP binary terminates at its memory limit | Only against a throwaway local CLI process | None | MIT |
204
+ | squiblyza php_vuln_scan | Version fingerprint from headers/markup, plus version-disclosure hunting | No | None | MIT |
205
+ | JoasASantos php2shell-scanner | Version fingerprint + subdomain recon + origin scan | Optional opt-in probe (README warns of DoS risk) | None | README states MIT; no LICENSE file |
206
+
207
+ Full criteria, the fairness notes (behavioral checks catch
208
+ backports; crash signals also misfire on some patched builds),
209
+ SSRF-safety comparison and an FAQ:
210
+ **[PHP vulnerability scanners compared](https://phpatch.com/compare)**.
211
+
212
+ ## What it doesn't do
213
+
214
+ - **It doesn't patch anything.** The scanner is read-only and
215
+ external; it cannot change your server.
216
+ - **It can't see a hidden version.** From outside, VERSION HIDDEN
217
+ is the honest end of the road — a definitive verdict for those
218
+ sites needs a read-only look on the server itself.
219
+ - **It doesn't monitor or alert.** It's a point-in-time check you
220
+ run; it won't notice next month's advisory for you.
221
+ - **It isn't a general security audit.** It judges the disclosed
222
+ PHP version against its advisory database — nothing more.
223
+
224
+ Patching, on-server verification, rollback, an evidence work
225
+ report, and monitoring are what the hosted service adds:
226
+ **[PHP Patch Assurance — phpatch.com](https://phpatch.com)**.
227
+
228
+ ## Guides and further reading
229
+
230
+ - **[Self-hoster guides](https://phpatch.com/guides)** — update
231
+ PHP in Docker and with apt, mod_php vs PHP-FPM, rollback,
232
+ health checks, version checks, end-of-life upgrades, and
233
+ patching without downtime, written from the patch flow the
234
+ service itself uses.
235
+ - **[php2shell fix guide](https://phpatch.com/threats/php2shell)**
236
+ — what php2shell is, who is affected, how to check and patch,
237
+ the fixed releases, and the primary sources.
238
+ - **[Case study: mitbbs.ai](https://phpatch.com/case-studies/mitbbs-ai)**
239
+ — how a bilingual news portal patched php2shell on disclosure
240
+ night: 8.3.35 to 8.3.36 under mod_php, about 10 seconds of
241
+ downtime, verified live, no compromise found.
242
+
243
+ ## Authorized use
244
+
245
+ Only scan sites you own or have permission to scan. The hosted
246
+ scanner at phpatch.com additionally enforces rate limits and an
247
+ opt-out blocklist for site operators (see the Terms, section 8).
248
+
249
+ ## Advisory data
250
+
251
+ `phpscan/data/cves.json` is the advisory database the scanner
252
+ judges against — branch floors, fixed releases, references and the
253
+ leak/exploitation timeline. Last updated 2026-10-22. Update
254
+ process: floors are reviewed against the php.net release
255
+ announcements on every PHP security release and at least monthly.
256
+ Corrections are welcome as pull requests; every change to this
257
+ file requires maintainer review (see CODEOWNERS), because it
258
+ changes the verdicts the scanner gives.
259
+
260
+ A verdict of `patched` means only that the disclosed version is
261
+ not below the fixed releases in this database — it is not a
262
+ general security guarantee. A version below a floor on a
263
+ distribution build (Ubuntu/Debian packages) is a "check" signal,
264
+ not proof: distros backport fixes without changing the upstream
265
+ version, and the scanner says so when it detects a distro build.
266
+
267
+ ## Support the project
268
+
269
+ phpscan is free and MIT-licensed, and the hosted scanner at
270
+ [phpatch.com](https://phpatch.com) is free for every site. If
271
+ the scanner, the threat desk or the daily Mega-Vuln Alert
272
+ reports helped you, you can chip in at
273
+ **[phpatch.com/donate](https://phpatch.com/donate)** — any
274
+ amount from $1, one-time, via Stripe. Donations fund phpscan
275
+ development, the free hosted scans and the public threat
276
+ research. Plainly stated on the page: PHPatch is operated by
277
+ Superstar Telecom LLC, a for-profit company, so donations are
278
+ voluntary support — not tax-deductible, and they buy no
279
+ product, service or entitlement.
280
+
281
+ ## Scanned by phpscan badge
282
+
283
+ If you scan your site with phpscan, you can show it:
284
+
285
+ [![Scanned by phpscan](https://img.shields.io/badge/scanned%20by-phpscan-blue.svg)](https://phpatch.com)
286
+
287
+ ```markdown
288
+ [![Scanned by phpscan](https://img.shields.io/badge/scanned%20by-phpscan-blue.svg)](https://phpatch.com)
289
+ ```
290
+
291
+ ## License
292
+
293
+ MIT. Not affiliated with the PHP Group. PHP is a trademark of the
294
+ PHP Group.
@@ -0,0 +1,11 @@
1
+ phpscan/__init__.py,sha256=cSsFxexdWPrCuqxiHn7TJXLOd6_xKnUP7L28Lgv3-Hc,659
2
+ phpscan/__main__.py,sha256=DrADbMIYtAwX2v_7kyKFXBwQq1CsA6E1GBQPHn7v05o,1501
3
+ phpscan/scan.py,sha256=maRaYbhqIkpOffEiZ4UeO3Q3aU-DrMBU2YlN3SOCHVc,19381
4
+ phpscan/versioning.py,sha256=t41-cRBeD6k4hVtrzQSBKtt9HHq5Z3iSuHBmsJ_208k,6705
5
+ phpscan/data/cves.json,sha256=BfQcph-_-qsahk4o7gNhZkh9WSWPkJ2AGZ8qxoNcpXM,2513
6
+ phpscan-1.4.4.dist-info/licenses/LICENSE,sha256=JJcWb6zutNQNCoxUfL1OPnUO8-_HIQWZVwVl2Iz5Pfw,1078
7
+ phpscan-1.4.4.dist-info/METADATA,sha256=jwZSFm_D2XQTqsvRIJ8ZK-IM8g4C6n8iz9OAXbggHxA,12356
8
+ phpscan-1.4.4.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ phpscan-1.4.4.dist-info/entry_points.txt,sha256=NqtI7kPoTLHH0y3LbsvZwXHKjl4ONIzP29V8bSywBHA,49
10
+ phpscan-1.4.4.dist-info/top_level.txt,sha256=VKM8ob5q-eJyegfCvUcuJPUMlu6nrveozgCPGLrhFV0,8
11
+ phpscan-1.4.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ phpscan = phpscan.__main__:cli
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Superstar Telecom LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
20
+ BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
21
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
22
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ phpscan