proxy2model 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proxy2model/__init__.py +19 -0
- proxy2model/__main__.py +6 -0
- proxy2model/atomic.py +68 -0
- proxy2model/cli/__init__.py +3 -0
- proxy2model/cli/analyze.py +501 -0
- proxy2model/cli/app.py +408 -0
- proxy2model/cli/browse.py +279 -0
- proxy2model/cli/build.py +399 -0
- proxy2model/cli/common.py +190 -0
- proxy2model/cli/history.py +224 -0
- proxy2model/cli/mcp.py +184 -0
- proxy2model/cli/ui.py +62 -0
- proxy2model/core/__init__.py +1 -0
- proxy2model/core/changes.py +124 -0
- proxy2model/core/context.py +571 -0
- proxy2model/core/diff.py +330 -0
- proxy2model/core/domains.py +98 -0
- proxy2model/core/graph.py +562 -0
- proxy2model/core/methodology.py +343 -0
- proxy2model/core/model.py +998 -0
- proxy2model/core/stack.py +218 -0
- proxy2model/data/fonts/LICENSES.md +16 -0
- proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/nippo-200.woff2 +0 -0
- proxy2model/data/fonts/nippo-300.woff2 +0 -0
- proxy2model/data/fonts/nippo-400.woff2 +0 -0
- proxy2model/data/fonts/nippo-500.woff2 +0 -0
- proxy2model/data/fonts/nippo-700.woff2 +0 -0
- proxy2model/data/public_suffix_list.dat +16497 -0
- proxy2model/db/__init__.py +1 -0
- proxy2model/db/bql.py +710 -0
- proxy2model/db/bql_js.py +278 -0
- proxy2model/db/store.py +547 -0
- proxy2model/export/__init__.py +1 -0
- proxy2model/export/openapi.py +248 -0
- proxy2model/ingest/__init__.py +1 -0
- proxy2model/ingest/browse.py +181 -0
- proxy2model/ingest/browser.py +1058 -0
- proxy2model/ingest/crawl.py +673 -0
- proxy2model/ingest/extract.py +208 -0
- proxy2model/ingest/infer.py +75 -0
- proxy2model/ingest/journey.py +148 -0
- proxy2model/ingest/live.py +96 -0
- proxy2model/ingest/parse.py +1275 -0
- proxy2model/ingest/redact.py +535 -0
- proxy2model/py.typed +0 -0
- proxy2model/recon/__init__.py +1 -0
- proxy2model/recon/osint.py +607 -0
- proxy2model/report/__init__.py +1 -0
- proxy2model/report/animate.py +163 -0
- proxy2model/report/dash.py +469 -0
- proxy2model/report/fonts.py +51 -0
- proxy2model/report/html.py +988 -0
- proxy2model/report/inventory.py +258 -0
- proxy2model/report/map.py +515 -0
- proxy2model/report/pages.py +537 -0
- proxy2model/report/query.py +306 -0
- proxy2model/report/shot.py +55 -0
- proxy2model/report/view.py +213 -0
- proxy2model-2.0.0.dist-info/METADATA +148 -0
- proxy2model-2.0.0.dist-info/RECORD +66 -0
- proxy2model-2.0.0.dist-info/WHEEL +5 -0
- proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
- proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
- proxy2model-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Passive/active OSINT for a target host — our own collector, standard library only.
|
|
3
|
+
|
|
4
|
+
`build` reads a capture you already made and never touches the network. This
|
|
5
|
+
module is the opposite kind of work: it reaches out to the target and to public
|
|
6
|
+
records to describe the host's *external* surface — DNS, TLS certificate, HTTP
|
|
7
|
+
security headers, technology hints, email authentication, certificate-transparency
|
|
8
|
+
subdomains, registration (RDAP) and a small set of common ports.
|
|
9
|
+
|
|
10
|
+
Because it sends traffic to the target and to third-party services (a DoH
|
|
11
|
+
resolver, a CT-log index, an RDAP server), it is never run by `build`. The CLI
|
|
12
|
+
exposes it as a separate `osint` command with an explicit authorization gate.
|
|
13
|
+
|
|
14
|
+
Every check is best-effort and independently sandboxed: a failure is recorded as
|
|
15
|
+
an `error` on that section, never raised, so one dead lookup can't sink the run.
|
|
16
|
+
Nothing here is called a vulnerability; observations are facts + light notes.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import concurrent.futures
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import socket
|
|
25
|
+
import ssl
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
28
|
+
import urllib.request
|
|
29
|
+
from dataclasses import dataclass, field, asdict
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
|
|
32
|
+
from .. import __version__
|
|
33
|
+
|
|
34
|
+
USER_AGENT = f"proxy2model-osint/{__version__} (+https://falc0n-researcher.github.io/proxy2model)"
|
|
35
|
+
DOH_URL = "https://dns.google/resolve"
|
|
36
|
+
CT_URL = "https://crt.sh/?q=%25.{domain}&output=json"
|
|
37
|
+
RDAP_BOOTSTRAP = "https://rdap.org/domain/{domain}"
|
|
38
|
+
ARCHIVE_URL = "https://archive.org/wayback/available?url={url}"
|
|
39
|
+
|
|
40
|
+
DNS_TYPES = ("A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA", "CAA")
|
|
41
|
+
COMMON_PORTS = {
|
|
42
|
+
21: "ftp", 22: "ssh", 25: "smtp", 53: "dns", 80: "http", 110: "pop3",
|
|
43
|
+
143: "imap", 443: "https", 445: "smb", 587: "smtp-submission", 993: "imaps",
|
|
44
|
+
995: "pop3s", 3000: "http-alt", 3306: "mysql", 3389: "rdp", 5432: "postgres",
|
|
45
|
+
6379: "redis", 8000: "http-alt", 8080: "http-proxy", 8443: "https-alt",
|
|
46
|
+
9200: "elasticsearch", 27017: "mongodb",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
SECURITY_HEADERS = {
|
|
50
|
+
"strict-transport-security": "HSTS — forces HTTPS",
|
|
51
|
+
"content-security-policy": "CSP — restricts resource origins",
|
|
52
|
+
"x-frame-options": "clickjacking protection",
|
|
53
|
+
"x-content-type-options": "MIME-sniffing protection",
|
|
54
|
+
"referrer-policy": "controls Referer leakage",
|
|
55
|
+
"permissions-policy": "restricts browser features",
|
|
56
|
+
"cross-origin-opener-policy": "cross-origin isolation",
|
|
57
|
+
"cross-origin-resource-policy": "cross-origin resource control",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Response-header / body signatures → a technology name. Intentionally small and
|
|
61
|
+
# conservative; a hint is a hint, not a version scanner.
|
|
62
|
+
_TECH_HEADER_SIGNS = [
|
|
63
|
+
("server", re.compile(r"nginx", re.I), "nginx"),
|
|
64
|
+
("server", re.compile(r"apache", re.I), "Apache"),
|
|
65
|
+
("server", re.compile(r"cloudflare", re.I), "Cloudflare"),
|
|
66
|
+
("server", re.compile(r"gunicorn", re.I), "Gunicorn"),
|
|
67
|
+
("server", re.compile(r"envoy", re.I), "Envoy"),
|
|
68
|
+
("server", re.compile(r"microsoft-iis", re.I), "IIS"),
|
|
69
|
+
("server", re.compile(r"\bcowboy\b", re.I), "Cowboy/Erlang"),
|
|
70
|
+
("x-powered-by", re.compile(r"php", re.I), "PHP"),
|
|
71
|
+
("x-powered-by", re.compile(r"express", re.I), "Express"),
|
|
72
|
+
("x-powered-by", re.compile(r"asp\.net", re.I), "ASP.NET"),
|
|
73
|
+
("x-powered-by", re.compile(r"next\.js", re.I), "Next.js"),
|
|
74
|
+
("x-aspnet-version", re.compile(r"."), "ASP.NET"),
|
|
75
|
+
("x-generator", re.compile(r"drupal", re.I), "Drupal"),
|
|
76
|
+
("x-drupal-cache", re.compile(r"."), "Drupal"),
|
|
77
|
+
("x-shopify-stage", re.compile(r"."), "Shopify"),
|
|
78
|
+
("x-vercel-id", re.compile(r"."), "Vercel"),
|
|
79
|
+
("x-served-by", re.compile(r"cache", re.I), "Fastly/Varnish"),
|
|
80
|
+
("via", re.compile(r"varnish", re.I), "Varnish"),
|
|
81
|
+
("set-cookie", re.compile(r"laravel_session", re.I), "Laravel"),
|
|
82
|
+
("set-cookie", re.compile(r"csrftoken|django", re.I), "Django"),
|
|
83
|
+
("set-cookie", re.compile(r"JSESSIONID", re.I), "Java/Servlet"),
|
|
84
|
+
("set-cookie", re.compile(r"connect\.sid", re.I), "Express"),
|
|
85
|
+
("set-cookie", re.compile(r"_shopify", re.I), "Shopify"),
|
|
86
|
+
]
|
|
87
|
+
_TECH_BODY_SIGNS = [
|
|
88
|
+
(re.compile(r"/_next/static/", re.I), "Next.js"),
|
|
89
|
+
(re.compile(r"window\.__NUXT__", re.I), "Nuxt.js"),
|
|
90
|
+
(re.compile(r"ng-version=", re.I), "Angular"),
|
|
91
|
+
(re.compile(r"data-reactroot|__REACT_DEVTOOLS", re.I), "React"),
|
|
92
|
+
(re.compile(r"wp-content/|wp-includes/", re.I), "WordPress"),
|
|
93
|
+
(re.compile(r"/sites/default/files|Drupal.settings", re.I), "Drupal"),
|
|
94
|
+
(re.compile(r"cdn\.shopify\.com", re.I), "Shopify"),
|
|
95
|
+
(re.compile(r"static\.parastorage\.com|X-Wix", re.I), "Wix"),
|
|
96
|
+
(re.compile(r"gstatic\.com/recaptcha|grecaptcha", re.I), "reCAPTCHA"),
|
|
97
|
+
(re.compile(r"vue(?:\.runtime)?(?:\.min)?\.js|data-v-[0-9a-f]{8}", re.I), "Vue.js"),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class OsintResult:
|
|
103
|
+
host: str
|
|
104
|
+
domain: str
|
|
105
|
+
generated_at: str
|
|
106
|
+
dns: dict = field(default_factory=dict)
|
|
107
|
+
tls: dict = field(default_factory=dict)
|
|
108
|
+
http: dict = field(default_factory=dict)
|
|
109
|
+
technology: list = field(default_factory=list)
|
|
110
|
+
email_security: dict = field(default_factory=dict)
|
|
111
|
+
subdomains: dict = field(default_factory=dict)
|
|
112
|
+
registration: dict = field(default_factory=dict)
|
|
113
|
+
files: dict = field(default_factory=dict)
|
|
114
|
+
archive: dict = field(default_factory=dict)
|
|
115
|
+
ports: dict = field(default_factory=dict)
|
|
116
|
+
ip_geo: dict = field(default_factory=dict)
|
|
117
|
+
notes: list = field(default_factory=list)
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict:
|
|
120
|
+
return asdict(self)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _now() -> str:
|
|
124
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_json(url: str, timeout: float):
|
|
128
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
|
|
129
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
130
|
+
return json.loads(r.read().decode("utf-8", "replace"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------ DNS ------
|
|
134
|
+
|
|
135
|
+
def _doh(name: str, rtype: str, timeout: float) -> list[str]:
|
|
136
|
+
data = _get_json(f"{DOH_URL}?name={urllib.parse.quote(name)}&type={rtype}", timeout)
|
|
137
|
+
out = []
|
|
138
|
+
for ans in data.get("Answer", []) or []:
|
|
139
|
+
val = ans.get("data", "").strip()
|
|
140
|
+
if val:
|
|
141
|
+
out.append(val.strip('"') if rtype == "TXT" else val.rstrip("."))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def collect_dns(domain: str, host: str, timeout: float) -> dict:
|
|
146
|
+
"""Each record type is looked up independently, so one failed lookup
|
|
147
|
+
cannot sink the rest."""
|
|
148
|
+
records: dict = {}
|
|
149
|
+
errors = []
|
|
150
|
+
for rtype in DNS_TYPES:
|
|
151
|
+
name = host if rtype in ("A", "AAAA", "CNAME") else domain
|
|
152
|
+
try:
|
|
153
|
+
vals = _doh(name, rtype, timeout)
|
|
154
|
+
if vals:
|
|
155
|
+
records[rtype] = vals
|
|
156
|
+
except Exception as e:
|
|
157
|
+
errors.append(f"{rtype}: {type(e).__name__}")
|
|
158
|
+
if host != domain:
|
|
159
|
+
for rtype in ("A", "AAAA", "CNAME"):
|
|
160
|
+
try:
|
|
161
|
+
vals = _doh(domain, rtype, timeout)
|
|
162
|
+
if vals:
|
|
163
|
+
records.setdefault("apex_" + rtype, vals)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
errors.append(f"apex_{rtype}: {type(e).__name__}")
|
|
166
|
+
if errors:
|
|
167
|
+
records["error"] = "; ".join(errors[:4])
|
|
168
|
+
return records
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------ TLS ------
|
|
172
|
+
|
|
173
|
+
def _rdn(seq) -> dict:
|
|
174
|
+
out = {}
|
|
175
|
+
for rdn in seq or ():
|
|
176
|
+
for k, v in rdn:
|
|
177
|
+
out[k] = v
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def collect_tls(host: str, port: int, timeout: float) -> dict:
|
|
182
|
+
info: dict = {}
|
|
183
|
+
try:
|
|
184
|
+
ctx = ssl.create_default_context()
|
|
185
|
+
with socket.create_connection((host, port), timeout=timeout) as sock:
|
|
186
|
+
with ctx.wrap_socket(sock, server_hostname=host) as ss:
|
|
187
|
+
cert = ss.getpeercert()
|
|
188
|
+
info["tls_version"] = ss.version()
|
|
189
|
+
cipher = ss.cipher()
|
|
190
|
+
if cipher:
|
|
191
|
+
info["cipher"] = cipher[0]
|
|
192
|
+
subject = _rdn(cert.get("subject"))
|
|
193
|
+
issuer = _rdn(cert.get("issuer"))
|
|
194
|
+
info["subject_cn"] = subject.get("commonName")
|
|
195
|
+
info["issuer"] = issuer.get("organizationName") or issuer.get("commonName")
|
|
196
|
+
info["not_before"] = cert.get("notBefore")
|
|
197
|
+
info["not_after"] = cert.get("notAfter")
|
|
198
|
+
sans = [v for t, v in cert.get("subjectAltName", ()) if t == "DNS"]
|
|
199
|
+
info["san"] = sorted(set(sans))[:60]
|
|
200
|
+
try:
|
|
201
|
+
exp = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
|
|
202
|
+
days = (exp - datetime.now(timezone.utc)).days
|
|
203
|
+
info["days_until_expiry"] = days
|
|
204
|
+
if days < 0:
|
|
205
|
+
info["note"] = "certificate is expired"
|
|
206
|
+
elif days < 14:
|
|
207
|
+
info["note"] = f"certificate expires in {days} days"
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
except ssl.SSLCertVerificationError as e:
|
|
211
|
+
info["error"] = f"certificate verification failed: {e.verify_message or e}"
|
|
212
|
+
# still record what the handshake itself can tell us
|
|
213
|
+
try:
|
|
214
|
+
ctx2 = ssl.create_default_context()
|
|
215
|
+
ctx2.check_hostname = False
|
|
216
|
+
ctx2.verify_mode = ssl.CERT_NONE
|
|
217
|
+
with socket.create_connection((host, port), timeout=timeout) as sock:
|
|
218
|
+
with ctx2.wrap_socket(sock, server_hostname=host) as ss:
|
|
219
|
+
info["tls_version"] = ss.version()
|
|
220
|
+
cipher = ss.cipher()
|
|
221
|
+
if cipher:
|
|
222
|
+
info["cipher"] = cipher[0]
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
except Exception as e:
|
|
226
|
+
info["error"] = f"{type(e).__name__}: {e}"
|
|
227
|
+
return info
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------ HTTP -----
|
|
231
|
+
|
|
232
|
+
def _fetch(url: str, timeout: float):
|
|
233
|
+
"""Fetch a URL. A 4xx/5xx still returns its status, headers and body —
|
|
234
|
+
a 403 front page carries security headers worth reading. The header object
|
|
235
|
+
is the raw message, so duplicate Set-Cookie headers survive (get_all)."""
|
|
236
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
237
|
+
ctx = ssl.create_default_context()
|
|
238
|
+
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx))
|
|
239
|
+
try:
|
|
240
|
+
r = opener.open(req, timeout=timeout)
|
|
241
|
+
except urllib.error.HTTPError as e:
|
|
242
|
+
r = e
|
|
243
|
+
with r:
|
|
244
|
+
body = r.read(300_000)
|
|
245
|
+
return r.status, r.headers, body.decode("utf-8", "replace"), r.geturl()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def collect_http(host: str, timeout: float) -> tuple[dict, str]:
|
|
249
|
+
out: dict = {}
|
|
250
|
+
body = ""
|
|
251
|
+
try:
|
|
252
|
+
status, headers, body, final = _fetch(f"https://{host}/", timeout)
|
|
253
|
+
low = {k.lower(): v for k, v in headers.items()}
|
|
254
|
+
out["status"] = status
|
|
255
|
+
out["final_url"] = final
|
|
256
|
+
present, missing = {}, []
|
|
257
|
+
for h, why in SECURITY_HEADERS.items():
|
|
258
|
+
if h in low:
|
|
259
|
+
present[h] = low[h][:200]
|
|
260
|
+
else:
|
|
261
|
+
missing.append(h)
|
|
262
|
+
out["security_headers_present"] = present
|
|
263
|
+
out["security_headers_missing"] = missing
|
|
264
|
+
interesting = ("server", "x-powered-by", "via", "x-cache", "cf-ray",
|
|
265
|
+
"x-frame-options", "content-type")
|
|
266
|
+
out["headers"] = {k: low[k][:200] for k in interesting if k in low}
|
|
267
|
+
cookies = headers.get_all("set-cookie") if hasattr(headers, "get_all") else []
|
|
268
|
+
flags = []
|
|
269
|
+
for c in cookies or []:
|
|
270
|
+
name = c.split("=", 1)[0].strip()
|
|
271
|
+
lc = c.lower()
|
|
272
|
+
flags.append({"name": name[:80], "secure": "secure" in lc,
|
|
273
|
+
"httponly": "httponly" in lc,
|
|
274
|
+
"samesite": bool(re.search(r"samesite", lc))})
|
|
275
|
+
if flags:
|
|
276
|
+
out["cookies"] = flags
|
|
277
|
+
out["_low_headers"] = low # consumed by tech + email checks, stripped later
|
|
278
|
+
except Exception as e:
|
|
279
|
+
out["error"] = f"{type(e).__name__}: {e}"
|
|
280
|
+
return out, body
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def fingerprint_tech(low_headers: dict, body: str) -> list[dict]:
|
|
284
|
+
found: dict[str, str] = {}
|
|
285
|
+
for hname, rx, tech in _TECH_HEADER_SIGNS:
|
|
286
|
+
val = low_headers.get(hname)
|
|
287
|
+
if val and rx.search(val):
|
|
288
|
+
found.setdefault(tech, f"header:{hname}")
|
|
289
|
+
for rx, tech in _TECH_BODY_SIGNS:
|
|
290
|
+
if body and rx.search(body):
|
|
291
|
+
found.setdefault(tech, "body")
|
|
292
|
+
gen = re.search(r'<meta[^>]+name=["\']generator["\'][^>]+content=["\']([^"\']+)', body or "", re.I)
|
|
293
|
+
if gen:
|
|
294
|
+
found.setdefault(gen.group(1).strip()[:60], "meta:generator")
|
|
295
|
+
return [{"name": k, "evidence": v} for k, v in sorted(found.items())]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------- email security -----
|
|
299
|
+
|
|
300
|
+
def collect_email_security(domain: str, dns_txt: list[str], timeout: float) -> dict:
|
|
301
|
+
out: dict = {}
|
|
302
|
+
try:
|
|
303
|
+
spf = next((t for t in dns_txt if t.lower().startswith("v=spf1")), None)
|
|
304
|
+
out["spf"] = spf
|
|
305
|
+
if spf and re.search(r"[~-]all", spf):
|
|
306
|
+
out["spf_note"] = "restrictive all qualifier present"
|
|
307
|
+
elif spf:
|
|
308
|
+
out["spf_note"] = "no restrictive all qualifier (+all/?all is weak)"
|
|
309
|
+
else:
|
|
310
|
+
out["spf_note"] = "no SPF record found"
|
|
311
|
+
dmarc = _doh(f"_dmarc.{domain}", "TXT", timeout)
|
|
312
|
+
rec = next((t for t in dmarc if t.lower().startswith("v=dmarc1")), None)
|
|
313
|
+
out["dmarc"] = rec
|
|
314
|
+
if rec:
|
|
315
|
+
m = re.search(r"\bp\s*=\s*(none|quarantine|reject)", rec, re.I)
|
|
316
|
+
out["dmarc_policy"] = m.group(1).lower() if m else "unset"
|
|
317
|
+
if out["dmarc_policy"] == "none":
|
|
318
|
+
out["dmarc_note"] = "policy p=none only monitors, does not block spoofing"
|
|
319
|
+
else:
|
|
320
|
+
out["dmarc_note"] = "no DMARC record found"
|
|
321
|
+
for sel in ("default", "google", "selector1", "k1", "dkim"):
|
|
322
|
+
got = _doh(f"{sel}._domainkey.{domain}", "TXT", timeout)
|
|
323
|
+
if any("dkim1" in t.lower() or "p=" in t.lower() for t in got):
|
|
324
|
+
out["dkim_selector_found"] = sel
|
|
325
|
+
break
|
|
326
|
+
except Exception as e:
|
|
327
|
+
out["error"] = f"{type(e).__name__}: {e}"
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------- subdomains ------
|
|
332
|
+
|
|
333
|
+
def collect_subdomains(domain: str, timeout: float, cap: int = 200) -> dict:
|
|
334
|
+
out: dict = {}
|
|
335
|
+
try:
|
|
336
|
+
data = _get_json(CT_URL.format(domain=domain), max(timeout, 15.0))
|
|
337
|
+
names: set[str] = set()
|
|
338
|
+
for row in data or []:
|
|
339
|
+
for n in str(row.get("name_value", "")).splitlines():
|
|
340
|
+
n = n.strip().lower().lstrip("*.")
|
|
341
|
+
# exact domain or a true subdomain — never evilexample.com
|
|
342
|
+
if (n == domain or n.endswith("." + domain)) and re.match(r"^[a-z0-9._-]+$", n):
|
|
343
|
+
names.add(n)
|
|
344
|
+
ordered = sorted(names)
|
|
345
|
+
out["count"] = len(ordered)
|
|
346
|
+
out["source"] = "certificate transparency logs"
|
|
347
|
+
out["names"] = ordered[:cap]
|
|
348
|
+
if len(ordered) > cap:
|
|
349
|
+
out["capped"] = cap
|
|
350
|
+
except Exception as e:
|
|
351
|
+
out["error"] = f"{type(e).__name__}: {e}"
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# --------------------------------------------------------- registration -----
|
|
356
|
+
|
|
357
|
+
def collect_registration(domain: str, timeout: float) -> dict:
|
|
358
|
+
out: dict = {}
|
|
359
|
+
try:
|
|
360
|
+
data = _get_json(RDAP_BOOTSTRAP.format(domain=domain), timeout)
|
|
361
|
+
events = {e.get("eventAction"): e.get("eventDate") for e in data.get("events", []) or []}
|
|
362
|
+
out["registration"] = events.get("registration")
|
|
363
|
+
out["expiration"] = events.get("expiration")
|
|
364
|
+
out["last_changed"] = events.get("last changed")
|
|
365
|
+
out["status"] = data.get("status", [])[:8]
|
|
366
|
+
registrar = None
|
|
367
|
+
for ent in data.get("entities", []) or []:
|
|
368
|
+
if "registrar" in (ent.get("roles") or []):
|
|
369
|
+
for item in ent.get("vcardArray", [[], []])[1]:
|
|
370
|
+
if item and item[0] == "fn":
|
|
371
|
+
registrar = item[3]
|
|
372
|
+
out["registrar"] = registrar
|
|
373
|
+
ns = sorted({n.get("ldhName", "").lower().rstrip(".") for n in data.get("nameservers", []) or []})
|
|
374
|
+
out["nameservers"] = [n for n in ns if n][:12]
|
|
375
|
+
except Exception as e:
|
|
376
|
+
out["error"] = f"{type(e).__name__}: {e}"
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ------------------------------------------------------------- files --------
|
|
381
|
+
|
|
382
|
+
def collect_files(host: str, timeout: float) -> dict:
|
|
383
|
+
out: dict = {}
|
|
384
|
+
for path, key in (("/robots.txt", "robots"), ("/.well-known/security.txt", "security_txt"),
|
|
385
|
+
("/sitemap.xml", "sitemap"), ("/.well-known/change-password", "change_password")):
|
|
386
|
+
try:
|
|
387
|
+
status, _h, body, _u = _fetch(f"https://{host}{path}", timeout)
|
|
388
|
+
if status == 200 and body.strip() and "<html" not in body[:200].lower():
|
|
389
|
+
if key == "robots":
|
|
390
|
+
disallow = re.findall(r"(?im)^\s*Disallow:\s*(\S+)", body)
|
|
391
|
+
sitemaps = re.findall(r"(?im)^\s*Sitemap:\s*(\S+)", body)
|
|
392
|
+
out["robots"] = {"present": True, "disallow": disallow[:40],
|
|
393
|
+
"sitemaps": sitemaps[:10]}
|
|
394
|
+
elif key == "security_txt":
|
|
395
|
+
out["security_txt"] = {"present": True,
|
|
396
|
+
"fields": re.findall(r"(?im)^([A-Za-z-]+):", body)[:12]}
|
|
397
|
+
else:
|
|
398
|
+
out[key] = {"present": True, "bytes": len(body)}
|
|
399
|
+
except Exception:
|
|
400
|
+
continue
|
|
401
|
+
return out
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------ archive -------
|
|
405
|
+
|
|
406
|
+
def collect_archive(host: str, timeout: float) -> dict:
|
|
407
|
+
try:
|
|
408
|
+
data = _get_json(ARCHIVE_URL.format(url=urllib.parse.quote(f"http://{host}/")), timeout)
|
|
409
|
+
snap = data.get("archived_snapshots", {}).get("closest")
|
|
410
|
+
if snap:
|
|
411
|
+
return {"archived": True, "last_snapshot": snap.get("timestamp"), "url": snap.get("url")}
|
|
412
|
+
return {"archived": False}
|
|
413
|
+
except Exception as e:
|
|
414
|
+
return {"error": f"{type(e).__name__}: {e}"}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# -------------------------------------------------------------- ports -------
|
|
418
|
+
|
|
419
|
+
def _probe(host: str, port: int, timeout: float) -> bool:
|
|
420
|
+
try:
|
|
421
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
422
|
+
return True
|
|
423
|
+
except Exception:
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def collect_ports(host: str, timeout: float, ports=None) -> dict:
|
|
428
|
+
ports = ports or COMMON_PORTS
|
|
429
|
+
open_ports = []
|
|
430
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
|
|
431
|
+
futs = {ex.submit(_probe, host, p, timeout): p for p in ports}
|
|
432
|
+
for fut in concurrent.futures.as_completed(futs):
|
|
433
|
+
if fut.result():
|
|
434
|
+
p = futs[fut]
|
|
435
|
+
open_ports.append({"port": p, "service": ports.get(p, "?")})
|
|
436
|
+
open_ports.sort(key=lambda d: d["port"])
|
|
437
|
+
return {"scanned": len(ports), "open": open_ports,
|
|
438
|
+
"note": "TCP connect only; a closed probe is not proof a service is absent"}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _resolve_ip(host: str, dns: dict) -> str | None:
|
|
442
|
+
a = dns.get("A") or []
|
|
443
|
+
if a:
|
|
444
|
+
return a[0]
|
|
445
|
+
try:
|
|
446
|
+
return socket.gethostbyname(host)
|
|
447
|
+
except Exception:
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def collect_ip_geo(ip: str, timeout: float) -> dict:
|
|
452
|
+
if not ip:
|
|
453
|
+
return {}
|
|
454
|
+
try:
|
|
455
|
+
data = _get_json(f"https://rdap.org/ip/{ip}", timeout)
|
|
456
|
+
return {"ip": ip, "network": data.get("name"),
|
|
457
|
+
"handle": data.get("handle"), "country": data.get("country"),
|
|
458
|
+
"type": data.get("type")}
|
|
459
|
+
except Exception as e:
|
|
460
|
+
return {"ip": ip, "error": f"{type(e).__name__}: {e}"}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ------------------------------------------------------------- orchestrate ---
|
|
464
|
+
|
|
465
|
+
def run(host: str, *, timeout: float = 6.0, want_ports: bool = False,
|
|
466
|
+
want_subdomains: bool = True, port_timeout: float = 1.5) -> OsintResult:
|
|
467
|
+
"""Collect the OSINT surface for one host. Each section is independent."""
|
|
468
|
+
from ..core.domains import registrable_domain
|
|
469
|
+
|
|
470
|
+
host = host.strip().lower().split("/")[0].split(":")[0]
|
|
471
|
+
domain = registrable_domain(host)
|
|
472
|
+
res = OsintResult(host=host, domain=domain, generated_at=_now())
|
|
473
|
+
|
|
474
|
+
res.dns = collect_dns(domain, host, timeout)
|
|
475
|
+
ip = _resolve_ip(host, res.dns)
|
|
476
|
+
|
|
477
|
+
res.tls = collect_tls(host, 443, timeout)
|
|
478
|
+
res.http, body = collect_http(host, timeout)
|
|
479
|
+
low = res.http.pop("_low_headers", {})
|
|
480
|
+
res.technology = fingerprint_tech(low, body)
|
|
481
|
+
res.email_security = collect_email_security(domain, res.dns.get("TXT", []), timeout)
|
|
482
|
+
if want_subdomains:
|
|
483
|
+
res.subdomains = collect_subdomains(domain, timeout)
|
|
484
|
+
res.registration = collect_registration(domain, timeout)
|
|
485
|
+
res.files = collect_files(host, timeout)
|
|
486
|
+
res.archive = collect_archive(host, timeout)
|
|
487
|
+
if ip:
|
|
488
|
+
res.ip_geo = collect_ip_geo(ip, timeout)
|
|
489
|
+
if want_ports:
|
|
490
|
+
res.ports = collect_ports(host, port_timeout)
|
|
491
|
+
|
|
492
|
+
status = res.http.get("status")
|
|
493
|
+
if isinstance(status, int) and status >= 400:
|
|
494
|
+
res.notes.append(f"front page answered {status}: header and cookie observations "
|
|
495
|
+
"describe that error page, not the application")
|
|
496
|
+
elif res.http.get("security_headers_missing"):
|
|
497
|
+
res.notes.append(f"{len(res.http['security_headers_missing'])} common security "
|
|
498
|
+
"header(s) not sent on the front page")
|
|
499
|
+
if res.tls.get("note"):
|
|
500
|
+
res.notes.append("TLS: " + res.tls["note"])
|
|
501
|
+
if res.email_security.get("dmarc_note"):
|
|
502
|
+
res.notes.append("Email: " + res.email_security["dmarc_note"])
|
|
503
|
+
return res
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def render_text(r: OsintResult) -> str:
|
|
507
|
+
"""A compact, human-readable summary. No value is invented; gaps say so."""
|
|
508
|
+
L: list[str] = []
|
|
509
|
+
L.append(f"OSINT · {r.host} (domain {r.domain}) {r.generated_at}")
|
|
510
|
+
L.append("")
|
|
511
|
+
|
|
512
|
+
def line(k, v):
|
|
513
|
+
L.append(f" {k:<16} {v}")
|
|
514
|
+
|
|
515
|
+
L.append("DNS")
|
|
516
|
+
if r.dns.get("error"):
|
|
517
|
+
line("error", r.dns["error"])
|
|
518
|
+
for t in DNS_TYPES:
|
|
519
|
+
if r.dns.get(t):
|
|
520
|
+
line(t, ", ".join(r.dns[t][:6]) + (" …" if len(r.dns[t]) > 6 else ""))
|
|
521
|
+
L.append("")
|
|
522
|
+
|
|
523
|
+
L.append("TLS")
|
|
524
|
+
if r.tls.get("error"):
|
|
525
|
+
line("error", r.tls["error"])
|
|
526
|
+
else:
|
|
527
|
+
line("version", r.tls.get("tls_version"))
|
|
528
|
+
line("issuer", r.tls.get("issuer"))
|
|
529
|
+
line("expires", f"{r.tls.get('not_after')} ({r.tls.get('days_until_expiry')} days)")
|
|
530
|
+
line("SAN", f"{len(r.tls.get('san', []))} names")
|
|
531
|
+
L.append("")
|
|
532
|
+
|
|
533
|
+
L.append("HTTP")
|
|
534
|
+
if r.http.get("error"):
|
|
535
|
+
line("error", r.http["error"])
|
|
536
|
+
else:
|
|
537
|
+
line("status", r.http.get("status"))
|
|
538
|
+
line("server", r.http.get("headers", {}).get("server", "—"))
|
|
539
|
+
line("sec headers", f"{len(r.http.get('security_headers_present', {}))} present, "
|
|
540
|
+
f"{len(r.http.get('security_headers_missing', []))} missing")
|
|
541
|
+
if r.http.get("security_headers_missing"):
|
|
542
|
+
line(" missing", ", ".join(r.http["security_headers_missing"]))
|
|
543
|
+
L.append("")
|
|
544
|
+
|
|
545
|
+
if r.technology:
|
|
546
|
+
L.append("Technology")
|
|
547
|
+
line("detected", ", ".join(t["name"] for t in r.technology))
|
|
548
|
+
L.append("")
|
|
549
|
+
|
|
550
|
+
L.append("Email security")
|
|
551
|
+
line("SPF", r.email_security.get("spf_note", "—"))
|
|
552
|
+
line("DMARC", r.email_security.get("dmarc_policy") or r.email_security.get("dmarc_note", "—"))
|
|
553
|
+
if r.email_security.get("dkim_selector_found"):
|
|
554
|
+
line("DKIM", f"selector '{r.email_security['dkim_selector_found']}' found")
|
|
555
|
+
L.append("")
|
|
556
|
+
|
|
557
|
+
if r.subdomains.get("count") is not None:
|
|
558
|
+
L.append(f"Subdomains ({r.subdomains['count']} via certificate transparency)")
|
|
559
|
+
for n in r.subdomains.get("names", [])[:15]:
|
|
560
|
+
L.append(f" {n}")
|
|
561
|
+
if r.subdomains.get("capped"):
|
|
562
|
+
L.append(f" … capped at {r.subdomains['capped']}")
|
|
563
|
+
L.append("")
|
|
564
|
+
|
|
565
|
+
if r.registration and not r.registration.get("error"):
|
|
566
|
+
L.append("Registration")
|
|
567
|
+
line("registrar", r.registration.get("registrar"))
|
|
568
|
+
line("created", r.registration.get("registration"))
|
|
569
|
+
line("expires", r.registration.get("expiration"))
|
|
570
|
+
L.append("")
|
|
571
|
+
|
|
572
|
+
if r.ip_geo and not r.ip_geo.get("error"):
|
|
573
|
+
L.append("Hosting")
|
|
574
|
+
line("IP", r.ip_geo.get("ip"))
|
|
575
|
+
line("network", r.ip_geo.get("network"))
|
|
576
|
+
if r.ip_geo.get("country"):
|
|
577
|
+
line("country", r.ip_geo.get("country"))
|
|
578
|
+
L.append("")
|
|
579
|
+
|
|
580
|
+
if r.files:
|
|
581
|
+
L.append("Well-known files")
|
|
582
|
+
if r.files.get("robots"):
|
|
583
|
+
line("robots.txt", f"{len(r.files['robots'].get('disallow', []))} Disallow rule(s)")
|
|
584
|
+
if r.files.get("security_txt"):
|
|
585
|
+
line("security.txt", "present")
|
|
586
|
+
if r.files.get("sitemap"):
|
|
587
|
+
line("sitemap.xml", "present")
|
|
588
|
+
L.append("")
|
|
589
|
+
|
|
590
|
+
if r.archive.get("archived"):
|
|
591
|
+
L.append("Archive")
|
|
592
|
+
line("wayback", f"last snapshot {r.archive.get('last_snapshot')}")
|
|
593
|
+
L.append("")
|
|
594
|
+
|
|
595
|
+
if r.ports.get("open"):
|
|
596
|
+
L.append("Open ports (TCP connect)")
|
|
597
|
+
for p in r.ports["open"]:
|
|
598
|
+
L.append(f" {p['port']:<6} {p['service']}")
|
|
599
|
+
L.append("")
|
|
600
|
+
|
|
601
|
+
if r.notes:
|
|
602
|
+
L.append("Notes")
|
|
603
|
+
for n in r.notes:
|
|
604
|
+
L.append(f" · {n}")
|
|
605
|
+
L.append("")
|
|
606
|
+
L.append("Observations only — nothing here is a vulnerability. Authorized targets only.")
|
|
607
|
+
return "\n".join(L)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The HTML report, the build animation and screenshots."""
|