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.
Files changed (66) hide show
  1. proxy2model/__init__.py +19 -0
  2. proxy2model/__main__.py +6 -0
  3. proxy2model/atomic.py +68 -0
  4. proxy2model/cli/__init__.py +3 -0
  5. proxy2model/cli/analyze.py +501 -0
  6. proxy2model/cli/app.py +408 -0
  7. proxy2model/cli/browse.py +279 -0
  8. proxy2model/cli/build.py +399 -0
  9. proxy2model/cli/common.py +190 -0
  10. proxy2model/cli/history.py +224 -0
  11. proxy2model/cli/mcp.py +184 -0
  12. proxy2model/cli/ui.py +62 -0
  13. proxy2model/core/__init__.py +1 -0
  14. proxy2model/core/changes.py +124 -0
  15. proxy2model/core/context.py +571 -0
  16. proxy2model/core/diff.py +330 -0
  17. proxy2model/core/domains.py +98 -0
  18. proxy2model/core/graph.py +562 -0
  19. proxy2model/core/methodology.py +343 -0
  20. proxy2model/core/model.py +998 -0
  21. proxy2model/core/stack.py +218 -0
  22. proxy2model/data/fonts/LICENSES.md +16 -0
  23. proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  25. proxy2model/data/fonts/nippo-200.woff2 +0 -0
  26. proxy2model/data/fonts/nippo-300.woff2 +0 -0
  27. proxy2model/data/fonts/nippo-400.woff2 +0 -0
  28. proxy2model/data/fonts/nippo-500.woff2 +0 -0
  29. proxy2model/data/fonts/nippo-700.woff2 +0 -0
  30. proxy2model/data/public_suffix_list.dat +16497 -0
  31. proxy2model/db/__init__.py +1 -0
  32. proxy2model/db/bql.py +710 -0
  33. proxy2model/db/bql_js.py +278 -0
  34. proxy2model/db/store.py +547 -0
  35. proxy2model/export/__init__.py +1 -0
  36. proxy2model/export/openapi.py +248 -0
  37. proxy2model/ingest/__init__.py +1 -0
  38. proxy2model/ingest/browse.py +181 -0
  39. proxy2model/ingest/browser.py +1058 -0
  40. proxy2model/ingest/crawl.py +673 -0
  41. proxy2model/ingest/extract.py +208 -0
  42. proxy2model/ingest/infer.py +75 -0
  43. proxy2model/ingest/journey.py +148 -0
  44. proxy2model/ingest/live.py +96 -0
  45. proxy2model/ingest/parse.py +1275 -0
  46. proxy2model/ingest/redact.py +535 -0
  47. proxy2model/py.typed +0 -0
  48. proxy2model/recon/__init__.py +1 -0
  49. proxy2model/recon/osint.py +607 -0
  50. proxy2model/report/__init__.py +1 -0
  51. proxy2model/report/animate.py +163 -0
  52. proxy2model/report/dash.py +469 -0
  53. proxy2model/report/fonts.py +51 -0
  54. proxy2model/report/html.py +988 -0
  55. proxy2model/report/inventory.py +258 -0
  56. proxy2model/report/map.py +515 -0
  57. proxy2model/report/pages.py +537 -0
  58. proxy2model/report/query.py +306 -0
  59. proxy2model/report/shot.py +55 -0
  60. proxy2model/report/view.py +213 -0
  61. proxy2model-2.0.0.dist-info/METADATA +148 -0
  62. proxy2model-2.0.0.dist-info/RECORD +66 -0
  63. proxy2model-2.0.0.dist-info/WHEEL +5 -0
  64. proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
  65. proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
  66. 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."""