Repository navigation
Expand file tree
/
Copy pathphp2shell_check.py
More file actions
executable file
·1733 lines (1515 loc) · 89.8 KB
/
Copy pathphp2shell_check.py
File metadata and controls
executable file
·1733 lines (1515 loc) · 89.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# GENERATED from php2shell_check/ by tools/build-single-file.py — do not edit
"""php2shell-check: is your site exposed to the October 2026 PHP flaws (php2shell)?
python3 php2shell_check.py example.com
curl -sSL https://raw.githubusercontent.com/spcaeo/php2shell-check/main/php2shell_check.py | python3 - example.com
Reads what a site's home page shows anyone (its headers, its generator tag, the paths of
the files it loads), after its redirects, plus a few public WordPress pages on WordPress
hosts of the same domain, and sets what it read against the advisories built into it.
GET and HEAD only, at most --max-requests requests, every one listed in the answer. It
never sends a form or a password, never asks for a secret file, never lists users, and it
does not test for the flaw.
Truth rules: every finding carries the exact lines it was read from, the URL and a curl
command that shows the same thing; what could not be read is "could not check", never a
guess; a version only when the server sent one; "update needed" only for a version read.
Safety: http and https on ports 80 and 443 only; never an IP address in any form, never a
name that only works inside a network; the name is resolved here and refused whole when any
of its addresses is not public (loopback, private, link-local, CGNAT, multicast, reserved,
cloud metadata; IPv4 inside IPv6 too), and the connection goes to the very address checked.
Every redirect is checked again. --allow-private lifts the private-address rules for a
check of your own internal site (cloud metadata, multicast and unspecified stay refused).
The source is the package php2shell_check/ (one module per concern); the single file
php2shell_check.py is generated from it by tools/build-single-file.py.
Python 3.9+, standard library only. Derived from Skald's public site check
(github.com/spcaeo/skald), relicensed MIT by its authors.
Copyright (c) 2026 Space-O Technologies. SPDX-License-Identifier: MIT
"""
from __future__ import annotations
import argparse
import http.client
import ipaddress
import json
import os
import re
import shlex
import shutil
import socket
import ssl
import sys
import textwrap
import time
from collections import Counter
from datetime import datetime, timezone
from urllib.parse import urljoin, urlsplit
# ------------------------------------------------------------------ safety.py
__version__ = "1.1.0"
HOME = "https://github.com/spcaeo/php2shell-check"
USER_AGENT = f"php2shell-check/{__version__} (+{HOME})"
ADVISORIES_URL = "https://raw.githubusercontent.com/spcaeo/php2shell-check/main/advisories.json"
WEB_CHECK = "https://skaldanalytics.com/check/"
TIMEOUT = 10.0 # seconds for one request (--timeout)
MAX_BYTES = 512 * 1024 # of one body
MAX_FETCHES = 8 # requests in one check, redirects included (--max-requests)
LOGIN_HOPS = 5 # redirects followed from /wp-login.php to its final page
MAX_REDIRECTS = 3
MAX_ADDRESSES = 2 # addresses tried per name, in the resolver's order
MAX_URL = 500
PORTS = {"http": 80, "https": 443}
REDIRECTS = (301, 302, 303, 307, 308)
class Refused(ValueError):
"""An address or a redirect a rule refuses, in plain words."""
class BadAddress(Refused):
"""What was typed is not a website address at all."""
class Unreachable(OSError):
"""No answer: the words say why."""
class Spent(Exception):
"""The check's requests are used up: what is left is not read."""
class Answer:
def __init__(self, status, headers=None, body=b"", url="", peer="", not_after=None):
self.status, self.headers, self.body = status, headers or {}, body
self.url, self.peer, self.not_after = url, peer, not_after # peer: never shown
# ------------------------------------------------------------------ addresses and names
NAME = re.compile(r"^(?=.{3,253}$)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?"
r"(?:\.[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)+$")
METADATA = {ipaddress.ip_address(a) for a in (
"169.254.169.254", "169.254.170.2", "169.254.169.253", "100.100.100.200", "fd00:ec2::254")}
NUMERIC_LABEL = re.compile(r"^(?:\d+|0x[0-9a-f]*)$", re.I) # 127.1, 0x7f.1: an address in disguise
CGNAT = ipaddress.ip_network("100.64.0.0/10")
NAT64 = ipaddress.ip_network("64:ff9b::/96")
NAT64_LOCAL = ipaddress.ip_network("64:ff9b:1::/48")
# Last labels that only work inside a network, or are reserved (RFC 2606, 6761, 6762, 8375).
INSIDE = {"localhost", "local", "internal", "intranet", "lan", "home", "corp", "private",
"localdomain", "test", "invalid", "example", "arpa", "onion", "alt"}
NOT_AN_ADDRESS = "That doesn't look like a website address."
def _embedded(ip) -> list:
"""The IPv4 addresses an IPv6 address carries (mapped, 6to4, Teredo, NAT64)."""
out = [x for x in (ip.ipv4_mapped, ip.sixtofour) if x]
if ip.teredo:
out.extend(ip.teredo)
if ip in NAT64 or ip in NAT64_LOCAL:
out.append(ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF))
return out
def address_refused(text: str, allow_private: bool = False):
"""Why this address is not fetched (plain words), or None for a public one."""
try:
ip = ipaddress.ip_address(str(text).split("%", 1)[0])
except ValueError:
return "an address that cannot be read"
if ip in METADATA:
return "a cloud metadata address"
if ip.version == 6:
for inner in _embedded(ip):
why = address_refused(str(inner), allow_private)
if why:
return why
if ip in NAT64 or ip in NAT64_LOCAL:
return None if ip in NAT64 or allow_private else "a private address"
if ip.is_unspecified:
return "an unspecified address"
if ip.is_multicast:
return "a multicast address"
if allow_private:
return None
for test, words in ((ip.is_loopback, "a loopback address (this machine itself)"),
(ip.is_link_local, "a link-local address"),
(ip.version == 4 and ip in CGNAT, "a shared (CGNAT) address"),
(ip.is_private, "a private address"), (ip.is_reserved, "a reserved address"),
(not ip.is_global, "an address that is not public")):
if test:
return words
return None
def lookup(host: str, port: int) -> list:
"""The addresses of `host` (the system resolver), in its order. Tests replace this."""
out = []
for *_, sockaddr in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP):
if sockaddr[0] not in out:
out.append(sockaddr[0])
return out
def resolve(host: str, port: int, allow_private: bool = False) -> list:
"""The addresses to connect to; Refused when ANY address of the name is not public."""
try:
literal = ipaddress.ip_address(host.strip("[]"))
except ValueError:
literal = None
if literal is not None:
why = address_refused(str(literal), allow_private)
if why:
raise Refused(f"The address is {why}, which this tool does not fetch.")
return [str(literal)]
try:
found = lookup(host, port)
except (OSError, UnicodeError):
raise Unreachable(f"{host} has no address in DNS.") from None
if not found:
raise Unreachable(f"{host} has no address in DNS.")
for address in found:
why = address_refused(address, allow_private)
if why:
raise Refused(f"{host} points to {why}, which this tool does not fetch."
+ ("" if allow_private else " (--allow-private checks a site of your own network.)"))
return found
def check_url(url: str) -> tuple:
"""(scheme, host, port, path and query) of a URL that may be fetched, or Refused."""
try:
parts = urlsplit(str(url).strip())
port = parts.port
except ValueError:
raise Refused("That is not an address this tool can fetch.") from None
scheme = (parts.scheme or "").lower()
if scheme not in PORTS:
raise Refused("Only http and https addresses are fetched.")
if parts.username is not None or parts.password is not None or "@" in parts.netloc:
raise Refused("An address with a user name or password is not fetched.")
if port is not None and port not in PORTS.values():
raise Refused("Only ports 80 and 443 are fetched.")
host = (parts.hostname or "").rstrip(".")
try:
ipaddress.ip_address(host)
is_ip = True
except ValueError:
is_ip = False
if not is_ip:
try:
host = host.encode("idna").decode("ascii").lower() if host else ""
except UnicodeError:
raise Refused("That is not a host name this tool can fetch.") from None
if not host or (not is_ip and (not NAME.match(host) or host.endswith(".localhost"))):
raise Refused("That is not a host name this tool can fetch.")
if not is_ip and NUMERIC_LABEL.match(host.rsplit(".", 1)[-1]):
raise Refused("An address written as numbers is not fetched.")
target = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
return scheme, host, port or PORTS[scheme], target
def host_refused(host: str, allow_private: bool = False):
"""Why this name is not checked (plain words), or None."""
host = host.lower().strip("[]").rstrip(".")
labels = host.split(".")
try:
ipaddress.ip_address(host)
is_ip = True
except ValueError:
is_ip = False
if allow_private:
return None
if is_ip or NUMERIC_LABEL.match(labels[-1]):
return "Type your site's name, not an IP address."
if labels[-1] in INSIDE:
return ("That name works only inside a network. Type the address people use on the "
"internet (or add --allow-private for a site of your own network).")
return None
def target(raw, allow_private: bool = False) -> tuple:
"""(scheme, host) of what someone typed (a host name, or a whole link), or Refused."""
text = str(raw or "").strip()
if not text or len(text) > MAX_URL or any(c.isspace() or ord(c) < 32 for c in text):
raise BadAddress(NOT_AN_ADDRESS)
if not re.match(r"^[a-z][a-z0-9+.-]*://", text, re.I):
if re.match(r"^[a-z][a-z0-9+.-]*:(?!\d)", text, re.I): # javascript:, file:, ...
raise BadAddress(NOT_AN_ADDRESS)
text = "https://" + text
try:
scheme, host, _port, _path = check_url(text)
except Refused as why:
if "port" in str(why).lower() or "password" in str(why).lower():
raise
raise BadAddress(NOT_AN_ADDRESS) from None
why = host_refused(host, allow_private)
if why:
raise Refused(why)
return scheme, host
def family(host: str) -> str:
"""The domain a name belongs to: <site> for wp.<site>; three labels under co.uk and the like."""
labels = host.lower().split(".")
n = 3 if len(labels) >= 3 and len(labels[-1]) == 2 and len(labels[-2]) <= 3 else 2
return ".".join(labels[-n:])
def host_of(url: str) -> str:
try:
return (urlsplit(url).hostname or "").lower()
except ValueError:
return ""
# ------------------------------------------------------------------ detect.py
PATH_CHARS = "[-A-Za-z0-9._~%/+,;=:@&?#!$*]*" # the same in Python and in grep -E
NAMES = {
"wordpress": "WordPress", "woocommerce": "WooCommerce", "drupal": "Drupal", "joomla": "Joomla",
"magento": "Magento", "laravel": "Laravel", "symfony": "Symfony", "codeigniter": "CodeIgniter",
"prestashop": "PrestaShop", "opencart": "OpenCart", "craft": "Craft CMS", "typo3": "TYPO3",
"statamic": "Statamic", "hugo": "Hugo", "php": "PHP", "nodejs": "Node.js", "shopify": "Shopify",
"wix": "Wix", "squarespace": "Squarespace", "webflow": "Webflow", "hubspot": "HubSpot CMS",
"ghost": "Ghost", "wordpresscom": "WordPress.com", "wpvip": "WordPress VIP", "nextjs": "Next.js",
"nuxt": "Nuxt", "gatsby": "Gatsby", "remix": "Remix", "sveltekit": "SvelteKit", "astro": "Astro",
"angular": "Angular Universal", "express": "Express", "vercel": "Vercel", "netlify": "Netlify",
"nginx": "nginx", "apache": "Apache", "litespeed": "LiteSpeed", "openresty": "OpenResty",
"iis": "Microsoft IIS", "caddy": "Caddy", "cloudflare": "Cloudflare", "fastly": "Fastly",
"akamai": "Akamai", "cloudfront": "CloudFront", "sucuri": "Sucuri"}
FIREWALLS = ("cloudflare", "sucuri", "akamai", "fastly", "cloudfront")
VER = r"(\d+(?:\.\d+){0,2})"
WP_ANY = f"((https?:)?//[a-z0-9.-]+)?/wp-(content|includes)/{PATH_CHARS}"
GENERATOR = r"<meta\b[^>]*\bname\s*=\s*[\"']?generator[\"']?[^>]*>"
class Page:
def __init__(self, url, host, status, headers=None, html=""):
self.url, self.host, self.status, self.headers, self.html = url, host, status, headers or {}, html
def header(self, name: str) -> str:
return ", ".join(self.headers.get(name.lower()) or [])
def cookies(self) -> list:
"""The cookie names the answer set (never their values)."""
out = []
for line in self.headers.get("set-cookie") or []:
name = line.split("=", 1)[0].strip()
if name and name not in out:
out.append(name)
return out
def _q(text: str) -> str:
return shlex.quote(text)
def see_header(pg: Page, name: str, contains=None) -> list:
"""The header line as received (the first value that contains `contains`)."""
pretty = {"x-powered-by": "X-Powered-By", "x-nf-request-id": "X-Nf-Request-Id"}.get(
name, "-".join(w.capitalize() for w in name.split("-")))
for value in pg.headers.get(name) or []:
if contains is None or contains.lower() in value.lower():
return [{"text": f"We saw the header {pretty}: {value.strip()[:200]}", "url": pg.url,
"command": f"curl -sI {_q(pg.url)} | grep -i '^{name}:'"}]
return []
def see_cookie(pg: Page, name: str) -> list:
return [{"text": f"We saw the site set a cookie named {name} (its value is not shown)",
"url": pg.url, "command": f"curl -sI {_q(pg.url)} | grep -i -o '^set-cookie: {name}='"}]
def see_generator(pg: Page, word: str) -> list:
"""The generator tag that names `word` (a page can carry several: a plugin's too)."""
for tag in re.findall(GENERATOR, pg.html, re.I):
if word.lower() in tag.lower():
return [{"text": f"We saw the tag {tag[:200]}", "url": pg.url,
"command": f"curl -s {_q(pg.url)} | grep -i -o '<meta[^>]*generator[^>]*>'"}]
return []
def generator(pg: Page) -> str:
out = []
for tag in re.findall(GENERATOR, pg.html, re.I):
got = re.search(r"\bcontent\s*=\s*[\"']([^\"']*)", tag, re.I)
if got:
out.append(got.group(1))
return " | ".join(out)[:200]
def files(pg: Page, pattern: str, what: str, keep=None) -> tuple:
"""(how many different matches, the first, its evidence) of an ERE that grep and Python
both read the same way. `keep`, a second ERE, keeps only the matches it starts."""
found = [m.group(0) for m in re.finditer(pattern, pg.html, re.I)]
if keep:
found = [m for m in found if re.match(keep, m, re.I)]
found = list(dict.fromkeys(m.lower() for m in found))
if not found:
return 0, None, []
n = len(found)
text = (f"We saw links to {n:,} different {what.format(s='' if n == 1 else 's')}, for example "
f"{found[0][:160]}")
then = f" | grep -i -E {_q(keep)}" if keep else ""
return n, found[0], [{"text": text, "url": pg.url,
"command": f"curl -s {_q(pg.url)} | grep -o -i -E {_q(pattern)}{then} "
f"| tr 'A-Z' 'a-z' | sort -u | wc -l"}]
def contains(pg: Page, needle: str, words: str) -> list:
if needle.lower() not in pg.html.lower():
return []
return [{"text": f"We saw {words} in the page", "url": pg.url,
"command": f"curl -s {_q(pg.url)} | grep -c -i {_q(needle)}"}]
def _add(out, product, fam, saw, version=None, runtime=None, patched=None) -> None:
if not saw:
return
for f in out:
if f["product"] == product:
f["saw"] += [s for s in saw if s not in f["saw"]]
f["version"] = f["version"] or version
return
out.append({"product": product, "name": NAMES[product], "version": version, "family": fam,
"runtime": runtime, "patched": patched, "saw": saw})
def _wordpress(pg: Page, out: list) -> None:
gen, host = generator(pg), re.escape(pg.host)
saw, version = [], None
got = re.search(r"WordPress\s+" + VER, gen, re.I)
if re.search(r"\bWordPress\b", gen, re.I) and not re.search(r"WordPress\.com", gen, re.I):
saw += see_generator(pg, "WordPress")
version = got.group(1) if got else None
saw += see_header(pg, "link", "api.w.org")
saw += files(pg, WP_ANY, f"file{{s}} under /wp-content/ or /wp-includes/ on {pg.host}",
keep=f"^((https?:)?//{host})?/")[2]
if not version:
# WordPress's own core files carry its version as ?ver= (never a plugin's or a theme's).
vers = Counter(re.findall(r"/wp-includes/(?:js|css)/[-A-Za-z0-9._/]*\?ver=([0-9]+\.[0-9]+(?:\.[0-9]+)?)\b",
pg.html, re.I))
if vers:
version, count = vers.most_common(1)[0]
saw.append({"text": f"We saw WordPress's own core files carry ?ver={version} ({count} of them)",
"url": pg.url, "command": f"curl -s {_q(pg.url)} | grep -o -E '/wp-includes/(js|css)/"
f"[-A-Za-z0-9._/]*[?]ver=[0-9.]*' | sort | uniq -c"})
_add(out, "wordpress", "cms", saw, version, "php")
wp = next((f for f in out if f["product"] == "wordpress"), None)
if wp:
n, _first, multi = files(pg, f"/wp-content/uploads/sites/[0-9]+/{PATH_CHARS}",
"file{s} under /wp-content/uploads/sites/ (a multisite's)")
if n:
wp["multisite"] = True
_add(out, "wordpress", "cms", multi)
got = re.search(r"WooCommerce\s+" + VER, gen, re.I)
_add(out, "woocommerce", "cms", see_generator(pg, "WooCommerce")
+ files(pg, f"/wp-content/plugins/woocommerce/{PATH_CHARS}", "WooCommerce file{s}")[2],
got.group(1) if got else None, "php")
def _php_apps(pg: Page, out: list) -> None:
gen, cookies = generator(pg), pg.cookies()
saw = see_generator(pg, "Drupal") if re.search(r"\bDrupal\b", gen, re.I) else []
saw += see_header(pg, "x-generator", "drupal") + see_header(pg, "x-drupal-cache")
saw += see_header(pg, "x-drupal-dynamic-cache")
saw += files(pg, f"/sites/default/files/{PATH_CHARS}", "file{s} under /sites/default/files/")[2]
got = re.search(r"Drupal\s+" + VER, gen + " " + pg.header("x-generator"), re.I)
_add(out, "drupal", "cms", saw, got.group(1) if got else None, "php")
saw = see_generator(pg, "Joomla") if re.search(r"\bJoomla\b", gen, re.I) else []
if saw:
saw += files(pg, f"/media/(jui|system)/{PATH_CHARS}", "file{s} under Joomla's /media/jui/ or /media/system/")[2]
saw += contains(pg, "/administrator/", "a link to /administrator/")
_add(out, "joomla", "cms", saw, None, "php")
saw = [h for name in ("x-magento-cache-debug", "x-magento-tags", "x-magento-vary") for h in see_header(pg, name)]
saw += contains(pg, "Mage.Cookies", "Magento's Mage.Cookies script")
saw += files(pg, f"/static/version[0-9]+/{PATH_CHARS}", "file{s} under Magento's /static/version.../")[2]
_add(out, "magento", "cms", saw, None, "php")
session = [c for c in cookies if c.lower().endswith("_session")]
if "XSRF-TOKEN" in cookies and session: # Laravel's own pair
_add(out, "laravel", "framework", see_cookie(pg, "XSRF-TOKEN") + see_cookie(pg, session[0]), None, "php")
_add(out, "statamic", "cms", see_header(pg, "x-powered-by", "statamic"), None, "php")
for product, cookie in (("laravel", "laravel_session"), ("symfony", "sf_redirect"),
("codeigniter", "ci_session"), ("craft", "CraftSessionId"), ("opencart", "OCSESSID")):
hit = [c for c in cookies if c.lower() == cookie.lower()]
fam = "cms" if product in ("craft", "opencart") else "framework"
_add(out, product, fam, see_cookie(pg, hit[0]) if hit else [], None, "php")
if any(c.lower().startswith("prestashop-") for c in cookies) or re.search(r"\bPrestaShop\b", gen, re.I):
saw = see_generator(pg, "PrestaShop") if re.search(r"\bPrestaShop\b", gen, re.I) else []
saw += [s for c in cookies if c.lower().startswith("prestashop-") for s in see_cookie(pg, c)][:1]
_add(out, "prestashop", "cms", saw, None, "php")
_add(out, "craft", "cms", see_header(pg, "x-powered-by", "craft cms"), None, "php")
saw = see_generator(pg, "TYPO3") if re.search(r"\bTYPO3\b", gen, re.I) else []
saw += files(pg, f"/typo3(conf|temp)/{PATH_CHARS}", "file{s} under /typo3conf/ or /typo3temp/")[2]
_add(out, "typo3", "cms", saw, None, "php")
if any(f["product"] == "opencart" for f in out):
_add(out, "opencart", "cms", files(pg, f"/catalog/view/theme/{PATH_CHARS}", "OpenCart theme file{s}")[2])
def _hosted(pg: Page, out: list) -> None:
gen = generator(pg)
saw = see_header(pg, "x-shopid") + see_header(pg, "x-shopify-stage") + see_header(pg, "powered-by", "shopify")
if saw:
saw += [s for c in pg.cookies() if c.lower().startswith("_shopify_") for s in see_cookie(pg, c)][:1]
saw += files(pg, f"//cdn\\.shopify\\.com/{PATH_CHARS}", "file{s} on cdn.shopify.com")[2]
_add(out, "shopify", "hosted", saw, patched="Shopify")
saw = see_header(pg, "x-wix-request-id") + see_header(pg, "server", "pepyaka")
saw += files(pg, f"//static\\.wixstatic\\.com/{PATH_CHARS}", "file{s} on static.wixstatic.com")[2] if saw else []
_add(out, "wix", "hosted", saw, patched="Wix")
saw = see_header(pg, "server", "squarespace")
saw += files(pg, f"//static1\\.squarespace\\.com/{PATH_CHARS}", "file{s} on static1.squarespace.com")[2] if saw else []
_add(out, "squarespace", "hosted", saw, patched="Squarespace")
saw = contains(pg, "data-wf-site", "Webflow's data-wf-site attribute") if "data-wf-site" in pg.html else []
saw += files(pg, f"//[a-z0-9.-]*website-files\\.com/{PATH_CHARS}", "file{s} on Webflow's website-files.com")[2] \
if saw else []
_add(out, "webflow", "hosted", saw, patched="Webflow")
_add(out, "hubspot", "hosted", see_header(pg, "x-hs-hub-id") + see_header(pg, "x-powered-by", "hubspot"),
patched="HubSpot")
saw = see_header(pg, "x-hacker") + see_header(pg, "host-header", "wordpress.com")
saw += see_generator(pg, "WordPress.com") if re.search(r"WordPress\.com", gen, re.I) else []
vip = see_header(pg, "x-powered-by", "wordpress vip")
_add(out, "wpvip", "hosted", vip, patched="WordPress VIP")
if not vip:
_add(out, "wordpresscom", "hosted", saw, patched="WordPress.com")
if re.search(r"\bGhost\b", gen, re.I):
got = re.search(r"Ghost\s+" + VER, gen, re.I)
_add(out, "ghost", "cms", see_generator(pg, "Ghost") + see_header(pg, "x-ghost-cache-status"),
got.group(1) if got else None, "nodejs")
def _js(pg: Page, out: list) -> None:
gen = generator(pg)
saw = see_header(pg, "x-powered-by", "next.js")
saw += files(pg, f"(https?://[a-z0-9.-]+)?/_next/{PATH_CHARS}", "Next.js file{s} (/_next/)")[2]
saw += contains(pg, "__NEXT_DATA__", "Next.js's __NEXT_DATA__ script")
_add(out, "nextjs", "js", saw, None, "nodejs")
saw = see_header(pg, "x-powered-by", "nuxt")
saw += files(pg, f"(https?://[a-z0-9.-]+)?/_nuxt/{PATH_CHARS}", "Nuxt file{s} (/_nuxt/)")[2]
saw += contains(pg, "__NUXT__", "Nuxt's __NUXT__ data")
_add(out, "nuxt", "js", saw, None, "nodejs")
got = re.search(r"Gatsby\s+" + VER, gen, re.I)
saw = (see_generator(pg, "Gatsby") if got else []) + contains(pg, "___gatsby", "Gatsby's ___gatsby root")
_add(out, "gatsby", "js", saw, got.group(1) if got else None)
_add(out, "remix", "js", contains(pg, "__remixContext", "Remix's __remixContext script"), None, "nodejs")
_add(out, "sveltekit", "js", files(pg, f"/_app/immutable/{PATH_CHARS}", "SvelteKit file{s} (/_app/immutable/)")[2],
None, "nodejs")
got = re.search(r"Hugo\s+" + VER, gen, re.I)
_add(out, "hugo", "js", see_generator(pg, "Hugo") if got else [], got.group(1) if got else None)
got = re.search(r"Astro\s+v?" + VER, gen, re.I)
saw = (see_generator(pg, "Astro") if got else []) + files(pg, f"/_astro/{PATH_CHARS}", "Astro file{s} (/_astro/)")[2]
_add(out, "astro", "js", saw, got.group(1) if got else None)
if "ng-server-context" in pg.html:
_add(out, "angular", "js", contains(pg, "ng-server-context", "Angular's ng-server-context "
"(rendered on a server)"), None, "nodejs")
_add(out, "express", "js", see_header(pg, "x-powered-by", "express"), None, "nodejs")
def _servers(pg: Page, out: list) -> None:
_add(out, "vercel", "hosting", see_header(pg, "x-vercel-id") + see_header(pg, "server", "vercel"), patched="Vercel")
_add(out, "netlify", "hosting", see_header(pg, "x-nf-request-id") + see_header(pg, "server", "netlify"),
patched="Netlify")
server = pg.header("server")
for product, word in (("nginx", "nginx"), ("apache", "apache"), ("litespeed", "litespeed"),
("openresty", "openresty"), ("iis", "microsoft-iis"), ("caddy", "caddy")):
got = re.match(r"^\s*" + re.escape(word) + r"(?:/" + VER + ")?", server, re.I)
if got:
_add(out, product, "server", see_header(pg, "server", word), got.group(1))
if pg.headers.get("x-litespeed-cache"):
_add(out, "litespeed", "server", see_header(pg, "x-litespeed-cache"))
_add(out, "cloudflare", "cdn", see_header(pg, "server", "cloudflare") + see_header(pg, "cf-ray"))
_add(out, "fastly", "cdn", see_header(pg, "x-fastly-request-id") + see_header(pg, "x-served-by", "cache-"))
_add(out, "akamai", "cdn", see_header(pg, "server", "akamaighost") + see_header(pg, "akamai-grn"))
_add(out, "cloudfront", "cdn", see_header(pg, "x-amz-cf-id") + see_header(pg, "via", "cloudfront"))
_add(out, "sucuri", "cdn", see_header(pg, "x-sucuri-id") + see_header(pg, "server", "sucuri"))
saw = see_header(pg, "x-powered-by", "php/")
got = re.search(r"\bPHP/" + VER, pg.header("x-powered-by"), re.I)
session = [c for c in pg.cookies() if c.upper() == "PHPSESSID"]
saw += see_cookie(pg, session[0]) if session else []
if saw:
_add(out, "php", "runtime", saw, got.group(1) if got else None, "php")
def detect(pg: Page) -> list:
"""Everything this page proves, each with its evidence: product, name, version (only what
the server sent), family, runtime, patched (who runs the runtime for you), saw."""
out: list = []
for fam in (_hosted, _wordpress, _php_apps, _js, _servers):
fam(pg, out)
host = next((f["patched"] for f in out if f["product"] in ("wordpresscom", "wpvip")), None)
if host: # WordPress hosted by WordPress.com or VIP
for f in out:
if f["product"] in ("wordpress", "woocommerce"):
f["patched"] = host
for f in out:
f["host"] = pg.host
return out
def how_of(saw: list) -> str:
text = saw[0]["text"] if saw else ""
if text.startswith("We saw the tag"):
return "generator tag"
got = re.match(r"We saw the header ([A-Za-z-]+):", text)
if got:
return f"{got.group(1)} header"
if text.startswith("We saw the site set a cookie"):
return "its cookie"
return "its files" if "load" in text or text.startswith("We saw links to") else "its page"
def runtimes(found: list) -> list:
"""PHP and Node.js on each host: the server's own version when it shows one, else "version
not shown" with what runs on them as the evidence, and who patches them for you."""
out = [f for f in found if f["product"] not in ("php", "nodejs")]
for host in dict.fromkeys(f["host"] for f in found):
here = [f for f in found if f["host"] == host]
patched = next((f["patched"] for f in here if f["family"] in ("hosted", "hosting") and f["patched"]), None)
for runtime in ("php", "nodejs"):
own = next((f for f in here if f["product"] == runtime), None)
apps = [f for f in here if f.get("runtime") == runtime and f["product"] != runtime]
if not own and not apps:
continue
# A hosted platform runs PHP or Node.js for you; Vercel and Netlify run Node.js, not PHP.
who = patched if (patched and (runtime == "nodejs" or patched not in ("Vercel", "Netlify"))) \
else next((a["patched"] for a in apps if a.get("patched")), None)
saw = list(own["saw"]) if own else []
if not (own and own.get("version")) and apps:
app = apps[0]
ver = f" {app['version']}" if app.get("version") else ""
saw.insert(0, {"text": f"We saw {host} runs {app['name']}{ver} ({how_of(app['saw'])}), "
f"and {app['name']} runs on {NAMES[runtime]}",
"url": app["saw"][0]["url"], "command": app["saw"][0]["command"]})
saw += [s for s in app["saw"] if s not in saw]
out.append({"product": runtime, "name": NAMES[runtime], "family": "runtime",
"version": own.get("version") if own else None, "runtime": runtime,
"patched": who, "host": host, "saw": saw,
"under": [a["name"] + (f" {a['version']}" if a.get("version") else "") for a in apps]})
return out
# ------------------------------------------------------------------ blocked.py
CHALLENGE = (b"challenge-platform", b"just a moment...", b"attention required!",
b"cf-browser-verification", b"captcha", b"checking your browser")
DENIAL = (b"access denied", b"you have been blocked", b"request blocked",
b"your access to this site has been limited")
# (name, where, what, contains): a header (by name, and a word in its value when given) or a
# phrase of the page that only that firewall sends.
FIREWALL_MARKS = (
("Cloudflare", "header", "cf-mitigated", None), ("Cloudflare", "header", "server", "cloudflare"),
("Cloudflare", "header", "cf-ray", None),
("Sucuri", "header", "x-sucuri-id", None), ("Sucuri", "header", "server", "sucuri"),
("Sucuri", "page", "sucuri website firewall", None),
("Akamai", "header", "server", "akamaighost"), ("Akamai", "header", "akamai-grn", None),
("AWS WAF", "header", "x-amzn-waf-action", None),
("CloudFront", "header", "x-amz-cf-id", None), ("CloudFront", "header", "via", "cloudfront"),
("Imperva", "header", "x-iinfo", None), ("Imperva", "header", "x-cdn", "imperva"),
("Imperva", "header", "x-cdn", "incapsula"),
("Fastly", "header", "x-fastly-request-id", None),
("Wordfence", "page", "generated by wordfence", None))
BLOCKED_SUMMARY = {
"firewall": ("Your site's firewall stopped this check from this machine, so we can't see what it "
"shows. That's often good news: it blocks automated scanners too."),
"password": "Your site asks for a password, so we can't see what it shows.",
}
BLOCKED_NEXT = {
"firewall": ("To see the details, run it from a machine the firewall lets through (your office "
"network, or the server itself), or check by hand:"),
"password": "To see the details, check on the server:",
}
def _first_header(got, name: str) -> str:
return ((got.headers.get(name) or [""])[0] or "").strip().lower()
def challenged(got) -> bool:
"""A bot challenge (Cloudflare's, AWS WAF's and the like) answered instead of the page."""
if _first_header(got, "cf-mitigated") == "challenge" or \
_first_header(got, "x-amzn-waf-action") in ("challenge", "captcha"):
return True
return got.status in (403, 429, 503) and any(m in got.body[:30000].lower() for m in CHALLENGE)
def firewalls_named(pg: Page, body: bytes) -> tuple:
"""(the firewalls this answer names itself, the line that proves each: one per name)."""
names, saw, low = [], [], body[:30000].lower()
for name, where, what, word in FIREWALL_MARKS:
if name in names:
continue
if where == "header":
line = see_header(pg, what, word)
else:
line = contains(pg, what, f"\"{what}\"") if what.encode() in low else []
if line:
names.append(name)
saw += line
return names, saw
def blocker(got, host: str):
"""What blocked the check (see the module's docstring), or None when nothing did."""
pg = Page(url=got.url, host=host, status=got.status, headers=got.headers,
html=got.body[:30000].decode("utf-8", "replace"))
low = got.body[:30000].lower()
names, named = firewalls_named(pg, got.body)
if challenged(got):
kind = "challenge"
elif got.status == 401:
kind = "password"
elif got.status in (403, 429) or (got.status == 503 and names and any(m in low for m in DENIAL)):
kind = "denied"
else:
return None
url = got.url
what = {"challenge": " (a bot challenge, not your page)", "password": " (it asks for a password)",
"denied": " (refused, not your page)"}[kind]
saw = [{"text": f"We saw {url} answer {got.status}{what}", "url": url,
"command": f"curl -sI {_q(url)} | head -n 1"}]
for name in ("cf-mitigated", "x-amzn-waf-action", "www-authenticate"):
saw += see_header(pg, name)
title = re.search(r"<title[^>]*>\s*([^<]{1,120}?)\s*</title>", pg.html, re.I)
marker = next((m.decode() for m in CHALLENGE + DENIAL if m in low), None)
if title and marker:
saw.append({"text": f"We saw the page title \"{title.group(1)}\"", "url": url,
"command": f"curl -s {_q(url)} | grep -o -i '<title>[^<]*</title>'"})
elif marker:
saw += contains(pg, marker, f"\"{marker}\"")
saw += [s for s in named if s not in saw]
if not names: # who answered, in its own words (nginx, Apache ...)
saw += see_header(pg, "server")
home = f"https://{host}/"
commands = ([] if kind == "password" else
[f"curl -sI {_q(home)} | grep -iE \"server|x-powered-by\""]) + ["php -v"]
return {"kind": kind, "status": got.status, "url": url, "firewalls": names, "saw": saw,
"commands": commands}
def blocked_words(block: dict) -> str:
return "password" if block["kind"] == "password" else "firewall"
def blocked_card(host: str, block: dict) -> dict:
"""The "could not check" card of a blocked check: its proof, why it is not a problem with
the site, and the commands."""
names, status = block["firewalls"], block["status"]
if block["kind"] == "password":
return {"kind": "unknown", "group": "check", "title": f"{host} asks for a password, so we could not check it",
"saw": block["saw"],
"means": (f"This means the site answered {status}: it asks for a password (HTTP "
"authentication), as a private or staging site often does. It is not a problem found: "
"we can't see what it shows, so we say nothing about it."),
"advice": "Check the PHP version on the server with php -v, and update if it is older than "
"the fixed release of its branch.",
"hosts": [host], "commands": block["commands"], "firewalls": names}
who = (f"{' and '.join(names) if len(names) < 3 else ', '.join(names[:-1]) + ' and ' + names[-1]} "
"answered for your site" if names
else "something in front of your site (a firewall or a server rule) answered")
what = "a bot challenge" if block["kind"] == "challenge" else f"a refusal ({status})"
return {"kind": "unknown", "group": "check", "title": f"{host}'s firewall stopped this check",
"saw": block["saw"],
"means": (f"This means {who} with {what} instead of your home page. This check ran from this "
"machine, and firewalls often let a browser through and stop scripts like this one. "
"It is not a problem with your site: we can't see what the page shows, so we say "
"nothing about it. That's often good news: it blocks automated scanners too."),
"advice": ("Run it from a machine the firewall lets through (your office network, or the server "
"itself). Or look yourself: the curl line below, from a machine it lets through, shows "
"the Server and X-Powered-By headers, and php -v on the server shows the PHP version."),
"hosts": [host], "commands": block["commands"], "firewalls": names}
# ------------------------------------------------------------------ fetch.py
class _PinnedHTTP(http.client.HTTPConnection):
def __init__(self, host, address, port, timeout):
super().__init__(host, port, timeout=timeout)
self._address = address
def connect(self):
self.sock = socket.create_connection((self._address, self.port), self.timeout)
class _PinnedHTTPS(http.client.HTTPSConnection):
def __init__(self, host, address, port, timeout):
self._ctx = ssl.create_default_context()
super().__init__(host, port, timeout=timeout, context=self._ctx)
self._address = address
def connect(self):
raw = socket.create_connection((self._address, self.port), self.timeout)
try:
self.sock = self._ctx.wrap_socket(raw, server_hostname=self.host)
except BaseException:
raw.close()
raise
def open_pinned(scheme, host, address, port, method, target, timeout, max_bytes, agent=USER_AGENT):
"""One request to `address`, named `host` (Host header and TLS name). Tests replace this."""
conn = (_PinnedHTTPS if scheme == "https" else _PinnedHTTP)(host, address, port, timeout)
shown = host if port == PORTS[scheme] else f"{host}:{port}"
try:
conn.request(method, target, headers={
"Host": shown, "User-Agent": agent, "Accept-Encoding": "identity",
"Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5", "Connection": "close"})
res = conn.getresponse()
headers: dict = {}
for name, value in res.getheaders():
headers.setdefault(name.lower(), []).append(value)
body = res.read(max_bytes) if method == "GET" else b""
not_after = None
if scheme == "https":
try:
not_after = (conn.sock.getpeercert() or {}).get("notAfter")
except (AttributeError, ValueError, OSError):
pass
return Answer(res.status, headers, body, f"{scheme}://{shown}{target}", address, not_after)
finally:
conn.close()
def _words(why) -> str:
if isinstance(why, (socket.timeout, TimeoutError)):
return "it did not answer in time"
if isinstance(why, ssl.SSLCertVerificationError):
return "its HTTPS certificate is not valid for this name"
if isinstance(why, ssl.SSLError):
return "its HTTPS connection failed"
if isinstance(why, ConnectionRefusedError):
return "it refused the connection"
if isinstance(why, http.client.HTTPException):
return "its answer could not be read"
return "the connection failed"
class Budget:
"""The check's limits (requests, seconds, the User-Agent, the address rules) and its log."""
def __init__(self, fetches=MAX_FETCHES, timeout=TIMEOUT, seconds=None, ua=USER_AGENT,
allow_private=False, clock=time.monotonic):
self.fetches, self.used, self.clock, self.timeout = fetches, 0, clock, timeout
self.ua, self.allow_private = ua, allow_private
self.end = clock() + (seconds if seconds is not None else timeout * max(fetches, 1) + 5)
self.log: list = []
def seconds(self) -> float:
left = min(self.timeout, self.end - self.clock())
if left <= 0.2:
raise Unreachable("it did not answer in time")
return left
def take(self) -> None:
if self.used >= self.fetches:
raise Spent()
self.seconds()
self.used += 1
def note(self, method, url, got=None, error=None) -> None:
row = {"method": method, "url": url[:300], "status": None, "bytes": None,
"location": None, "error": error}
if got is not None:
row["status"] = got.status
if method == "GET":
row["bytes"] = len(got.body)
else:
length = (got.headers.get("content-length") or [""])[0]
row["bytes"] = int(length) if length.isdigit() else None
row["location"] = (got.headers.get("location") or [""])[0].strip()[:300] or None
self.log.append(row)
def get(url: str, method: str, budget: Budget) -> Answer:
"""One request, no redirect followed: every rule checked, the name resolved once and the
connection made to the address that passed (no second lookup to rebind). Logged."""
try:
scheme, host, port, path = check_url(url)
why = host_refused(host, budget.allow_private)
if why:
raise Refused(why)
addresses = resolve(host, port, budget.allow_private)
except (Refused, Unreachable) as why:
budget.note(method, url, error=str(why))
raise
try:
budget.take()
except Spent:
budget.note(method, url, error=f"not fetched: the limit of {budget.fetches} requests")
raise
except Unreachable as why:
budget.note(method, url, error=str(why))
raise
last = None
for address in addresses[:MAX_ADDRESSES]:
try:
got = open_pinned(scheme, host, address, port, method, path, budget.seconds(), MAX_BYTES,
agent=budget.ua)
budget.note(method, url, got)
return got
except ssl.SSLCertVerificationError as err:
budget.note(method, url, error=_words(err))
raise Unreachable(_words(err)) from None
except (OSError, http.client.HTTPException) as err:
last = err
words = _words(last) if last else "the connection failed"
budget.note(method, url, error=words)
raise Unreachable(words)
def location(got, base: str):
if got.status not in REDIRECTS:
return None
loc = (got.headers.get("location") or [""])[0].strip()
return urljoin(got.url or base, loc) if loc else None
def follow(url: str, budget: Budget) -> Answer:
"""A GET at the end of at most MAX_REDIRECTS redirects, each hop checked as `get`."""
for hop in range(MAX_REDIRECTS + 1):
got = get(url, "GET", budget)
nxt = location(got, url)
if not nxt:
return got
if hop == MAX_REDIRECTS:
break
url = nxt
raise Refused(f"It redirected more than {MAX_REDIRECTS} times.")
# ------------------------------------------------------------------ the check's requests
LOGIN_FORM = re.compile(r"id\s*=\s*[\"']loginform[\"']", re.I)
LOG, PWD = (re.compile(rf"name\s*=\s*[\"']{n}[\"']", re.I) for n in ("log", "pwd"))
HIDDEN = (401, 403, 404, 410)
SECURITY_HEADERS = ("strict-transport-security", "content-security-policy", "x-frame-options",
"x-content-type-options", "referrer-policy")
def plain(words: str, timeout: float) -> str:
"""Why a site could not be read, in plain words."""
low = (words or "").lower()
for seen, said in (("did not answer in time", f"no answer in {timeout:g} seconds"),
("no address in dns", "the name doesn't exist"),
("certificate is not valid", "its certificate is invalid"),
("https connection failed", "its secure connection failed"),
("refused the connection", "it refused the connection"),
("could not be read", "its answer could not be read")):
if seen in low:
return said
return words or "it did not answer"
def page_of(got, html=None) -> Page:
if html is None:
html = (got.body or b"")[:MAX_BYTES].decode("utf-8", "replace")
return Page(got.url, host_of(got.url), got.status, got.headers, html)
def origins_of(pg: Page, typed: str, allow_private: bool) -> list:
"""Other hosts of the site's own domain its page loads WordPress files from, most first."""
counts: Counter = Counter()
for m in re.finditer(WP_ANY, pg.html, re.I):
raw = m.group(1) or ""
host = host_of("https:" + raw if raw.startswith("//") else raw)
if host and host not in (pg.host, typed) and family(host) == family(typed) \
and not host_refused(host, allow_private):
counts[host] += 1
return [h for h, _n in counts.most_common()]
def login(scheme: str, host: str, budget: Budget) -> dict:
"""/wp-login.php followed to its final page within the domain, its body read: "open" only
for WordPress's login form; hidden for 401/403/404/410, a page without it or another domain."""
url = f"{scheme}://{host}/wp-login.php"
chain, current = [], url
for _hop in range(LOGIN_HOPS + 1):
try:
got = get(current, "GET", budget)
except Spent:
return {"state": "unknown", "url": url, "chain": chain,
"why": f"not fetched: the limit of {budget.fetches} requests"}
except (Refused, Unreachable) as why:
return {"state": "unknown", "url": url, "chain": chain, "why": str(why)}
nxt = location(got, current)
chain.append({"url": current, "status": got.status, "location": nxt})
if nxt:
if family(host_of(nxt)) != family(host):
return {"state": "hidden", "url": url, "chain": chain, "why": "it sends visitors to another domain"}
current = nxt
continue
if challenged(got):
return {"state": "unknown", "url": url, "chain": chain, "why": "a bot challenge answered instead of the page"}
body = got.body.decode("utf-8", "replace")
if 200 <= got.status < 300:
form = bool(LOGIN_FORM.search(body) or (LOG.search(body) and PWD.search(body)))
return {"state": "open" if form else "hidden", "url": url, "chain": chain,
"why": "a WordPress login form" if form else "a page with no login form"}
state = "hidden" if got.status in HIDDEN else "unknown"
return {"state": state, "url": url, "chain": chain, "why": f"it answers {got.status}"}
return {"state": "unknown", "url": url, "chain": chain, "why": "too many redirects"}
def once(url: str, budget: Budget):
try:
return get(url, "GET", budget)
except (Spent, Refused, Unreachable):
return None
def wordpress_extras(scheme: str, host: str, budget: Budget) -> dict:
"""xmlrpc.php (is XML-RPC on?) and readme.html (does it show a version?): one GET each."""
out = {}
got = once(f"{scheme}://{host}/xmlrpc.php", budget)
if got is not None:
on = got.status == 405 and b"accepts post requests only" in got.body.lower()
out["xmlrpc"] = {"url": got.url, "status": got.status, "on": on}
got = once(f"{scheme}://{host}/readme.html", budget)
if got is not None:
ver = re.search(rb"<br\s*/?>\s*Version\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)", got.body, re.I)
out["readme"] = {"url": got.url, "status": got.status,
"version": ver.group(1).decode() if ver and got.status == 200 else None}
return out
def origin(host: str, site: Page, budget: Budget) -> dict:
"""A WordPress host of the site's domain its page loads files from: proven by those files;
then its login page, /wp-json/ (HEAD: its headers) and its home page (HEAD: headless?)."""
_n, _first, loaded = files(site, WP_ANY, f"file{{s}} under /wp-content/ or /wp-includes/ from {host}",
keep=f"^(https?:)?//{re.escape(host)}/")
for line in loaded:
line["text"] = line["text"].replace("We saw links to", f"We saw the page {site.url} load", 1)
found = [{"product": "wordpress", "name": "WordPress", "version": None, "family": "cms",
"runtime": "php", "patched": None, "saw": loaded, "host": host}]
row = {"host": host, "role": "content origin", "found": found, "error": None,
"login": login("https", host, budget)}
try:
got = get(f"https://{host}/wp-json/", "HEAD", budget)
except (Spent, Refused, Unreachable):
got = None
if got is not None and not challenged(got):
for f in detect(page_of(got, "")):
if f["product"] == "wordpress":
found[0]["saw"] += f["saw"]
elif f["family"] in ("server", "cdn", "runtime"):