osintkit 0.1.1__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.
@@ -0,0 +1,190 @@
1
+ """GitHub public API collectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from urllib.parse import quote
7
+
8
+ from .. import errors
9
+ from .base import Context, collector
10
+
11
+ API = "https://api.github.com"
12
+
13
+
14
+ def _headers(ctx: Context, extra: dict[str, str] | None = None) -> dict[str, str]:
15
+ headers = {
16
+ "Accept": "application/vnd.github+json",
17
+ "X-GitHub-Api-Version": "2022-11-28",
18
+ }
19
+ if ctx.github_token:
20
+ headers["Authorization"] = f"Bearer {ctx.github_token}"
21
+ if extra:
22
+ headers.update(extra)
23
+ return headers
24
+
25
+
26
+ def _trim_user(user: dict) -> dict:
27
+ return {
28
+ "login": user.get("login"),
29
+ "name": user.get("name"),
30
+ "company": user.get("company"),
31
+ "blog": user.get("blog") or None,
32
+ "location": user.get("location"),
33
+ "bio": user.get("bio"),
34
+ "twitter_username": user.get("twitter_username"),
35
+ "public_repos": user.get("public_repos"),
36
+ "public_gists": user.get("public_gists"),
37
+ "followers": user.get("followers"),
38
+ "following": user.get("following"),
39
+ "created_at": user.get("created_at"),
40
+ "updated_at": user.get("updated_at"),
41
+ "url": user.get("html_url"),
42
+ }
43
+
44
+
45
+ @collector("github_profile", ("username",), "GitHub profile and recent repositories", order=50)
46
+ async def github_profile(ctx: Context) -> dict | None:
47
+ login = quote(ctx.target)
48
+ response = await ctx.http.get(
49
+ f"{API}/users/{login}",
50
+ namespace="github",
51
+ headers=_headers(ctx),
52
+ empty_on=(404,),
53
+ )
54
+ if response.status_code == 404:
55
+ return None
56
+ if response.status_code == 403:
57
+ raise errors.RateLimited("GitHub API rate limit reached")
58
+ user = response.json()
59
+ if not isinstance(user, dict):
60
+ return None
61
+
62
+ repos: list[dict] = []
63
+ repos_response = await ctx.http.get(
64
+ f"{API}/users/{login}/repos",
65
+ namespace="github",
66
+ params={"per_page": "50", "sort": "updated"},
67
+ headers=_headers(ctx),
68
+ empty_on=(404,),
69
+ )
70
+ if repos_response.status_code == 200:
71
+ payload = repos_response.json()
72
+ if isinstance(payload, list):
73
+ repos = [
74
+ {
75
+ "name": r.get("name"),
76
+ "description": (r.get("description") or "")[:200],
77
+ "language": r.get("language"),
78
+ "stars": r.get("stargazers_count"),
79
+ "fork": r.get("fork"),
80
+ "updated_at": r.get("updated_at"),
81
+ "url": r.get("html_url"),
82
+ }
83
+ for r in payload
84
+ if isinstance(r, dict)
85
+ ]
86
+
87
+ return {"profile": _trim_user(user), "repositories": repos}
88
+
89
+
90
+ @collector("github_commit_search", ("email",), "Commits authored with this email", order=50)
91
+ async def github_commit_search(ctx: Context) -> dict:
92
+ response = await ctx.http.get(
93
+ f"{API}/search/commits",
94
+ namespace="github",
95
+ params={"q": f'author-email:"{ctx.target}"', "per_page": "50"},
96
+ headers=_headers(ctx, {"Accept": "application/vnd.github.cloak-preview+json"}),
97
+ )
98
+ body = response.json()
99
+ items = body.get("items") if isinstance(body, dict) else None
100
+ if not isinstance(items, list):
101
+ return {"total": 0, "commits": []}
102
+ commits = []
103
+ for item in items:
104
+ commit = item.get("commit") or {}
105
+ author = commit.get("author") or {}
106
+ commits.append(
107
+ {
108
+ "repo": (item.get("repository") or {}).get("full_name"),
109
+ "message": (commit.get("message") or "").splitlines()[0][:160],
110
+ "author_name": author.get("name"),
111
+ "author_date": author.get("date"),
112
+ "url": item.get("html_url"),
113
+ }
114
+ )
115
+ return {"total": body.get("total_count", len(commits)), "commits": commits}
116
+
117
+
118
+ @collector("github_user_search", ("email", "company"), "GitHub user search", order=51)
119
+ async def github_user_search(ctx: Context) -> dict:
120
+ query = f'"{ctx.target}"' if ctx.target_type == "email" else f'company:"{ctx.target}"'
121
+ response = await ctx.http.get(
122
+ f"{API}/search/users",
123
+ namespace="github",
124
+ params={"q": query, "per_page": "30"},
125
+ headers=_headers(ctx),
126
+ )
127
+ body = response.json()
128
+ items = body.get("items") if isinstance(body, dict) else None
129
+ if not isinstance(items, list):
130
+ return {"total": 0, "users": []}
131
+ return {
132
+ "total": body.get("total_count", len(items)),
133
+ "users": [
134
+ {"login": u.get("login"), "url": u.get("html_url"), "type": u.get("type")}
135
+ for u in items
136
+ if isinstance(u, dict)
137
+ ],
138
+ }
139
+
140
+
141
+ LEGAL_SUFFIX = re.compile(r"\b(inc|llc|ltd|limited|gmbh|corp|corporation|plc|ag|sa|bv|oy|pty)\.?$", re.I)
142
+
143
+
144
+ def org_slugs(company: str) -> list[str]:
145
+ base = company.strip().lower()
146
+ variants = [base, LEGAL_SUFFIX.sub("", base).strip()]
147
+ slugs = []
148
+ for variant in variants:
149
+ slug = re.sub(r"[^a-z0-9]+", "-", variant).strip("-")
150
+ if slug and slug not in slugs:
151
+ slugs.append(slug)
152
+ return slugs
153
+
154
+
155
+ @collector("github_org_repos", ("company",), "GitHub repositories owned by the company", order=52)
156
+ async def github_org_repos(ctx: Context) -> dict:
157
+ slugs = org_slugs(ctx.target)
158
+ slug = slugs[0]
159
+ response = None
160
+ for candidate in slugs:
161
+ response = await ctx.http.get(
162
+ f"{API}/orgs/{quote(candidate)}/repos",
163
+ namespace="github",
164
+ params={"per_page": "50", "sort": "updated"},
165
+ headers=_headers(ctx),
166
+ empty_on=(404,),
167
+ )
168
+ if response.status_code != 404:
169
+ slug = candidate
170
+ break
171
+ if response is None or response.status_code == 404:
172
+ return {"org": slug, "tried": slugs, "found": False, "repositories": []}
173
+ payload = response.json()
174
+ if not isinstance(payload, list):
175
+ return {"org": slug, "found": False, "repositories": []}
176
+ return {
177
+ "org": slug,
178
+ "found": True,
179
+ "repositories": [
180
+ {
181
+ "name": r.get("name"),
182
+ "description": (r.get("description") or "")[:200],
183
+ "language": r.get("language"),
184
+ "stars": r.get("stargazers_count"),
185
+ "url": r.get("html_url"),
186
+ }
187
+ for r in payload
188
+ if isinstance(r, dict)
189
+ ],
190
+ }
@@ -0,0 +1,237 @@
1
+ """Paste dumps and public code mentions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from urllib.parse import quote
7
+
8
+ from .. import errors
9
+ from .base import Context, collector
10
+
11
+ PSBDMP = "https://psbdmp.ws/api/v3"
12
+ TAG_RE = re.compile(r"<[^>]+>")
13
+ SPACE_RE = re.compile(r"\s+")
14
+ GIST_RE = re.compile(r'href="/([A-Za-z0-9][A-Za-z0-9-]*)/([0-9a-f]{20,})"')
15
+
16
+
17
+ def snippet(text: str, needle: str, width: int = 140) -> str:
18
+ """Plain-text window around the needle."""
19
+ clean = SPACE_RE.sub(" ", TAG_RE.sub(" ", text or "")).strip()
20
+ if not clean:
21
+ return ""
22
+ index = clean.lower().find(needle.lower())
23
+ if index == -1:
24
+ return clean[:width]
25
+ start = max(0, index - width // 2)
26
+ end = min(len(clean), index + len(needle) + width // 2)
27
+ return ("…" if start else "") + clean[start:end] + ("…" if end < len(clean) else "")
28
+
29
+
30
+ @collector("psbdmp_search", ("email", "username", "domain"), "Psbdmp paste archive mentions", order=70)
31
+ async def psbdmp_search(ctx: Context) -> dict:
32
+ try:
33
+ response = await ctx.http.get(
34
+ f"{PSBDMP}/search/{quote(ctx.target)}",
35
+ namespace="psbdmp",
36
+ empty_on=(404, 500),
37
+ timeout=ctx.http_timeout,
38
+ )
39
+ except (errors.SourceUnavailable, errors.RateLimited) as exc:
40
+ return await _gist_fallback(ctx, f"psbdmp unavailable ({exc})")
41
+
42
+ if response.status_code != 200:
43
+ return await _gist_fallback(ctx, f"psbdmp HTTP {response.status_code}")
44
+ try:
45
+ body = response.json()
46
+ except ValueError:
47
+ return await _gist_fallback(ctx, "psbdmp returned non-JSON")
48
+ entries = body.get("data") if isinstance(body, dict) else None
49
+ if not isinstance(entries, list):
50
+ return await _gist_fallback(ctx, "psbdmp returned an unexpected shape")
51
+ pastes = []
52
+ for entry in entries[:25]:
53
+ if not isinstance(entry, dict):
54
+ continue
55
+ paste_id = entry.get("id")
56
+ pastes.append(
57
+ {
58
+ "id": paste_id,
59
+ "url": f"https://pastebin.com/{paste_id}" if paste_id else None,
60
+ "archive_url": f"https://psbdmp.ws/api/v3/dump/{paste_id}" if paste_id else None,
61
+ "tags": entry.get("tags"),
62
+ "length": entry.get("len"),
63
+ "created": entry.get("time"),
64
+ "snippet": (entry.get("snippet") or "")[:400],
65
+ }
66
+ )
67
+ if not pastes:
68
+ return await _gist_fallback(ctx, "psbdmp had no hits")
69
+ return {"count": body.get("count", len(pastes)), "pastes": pastes, "source": "psbdmp"}
70
+
71
+
72
+ async def _gist_fallback(ctx: Context, note: str) -> dict:
73
+ """Public GitHub Gist search, used when the paste archive is unreachable."""
74
+ try:
75
+ response = await ctx.http.get(
76
+ "https://gist.github.com/search",
77
+ namespace="gist",
78
+ params={"q": ctx.target},
79
+ accept="text/html",
80
+ empty_on=(403, 404, 429, 500),
81
+ timeout=ctx.http_timeout,
82
+ )
83
+ except (errors.SourceUnavailable, errors.RateLimited) as exc:
84
+ return {"count": 0, "pastes": [], "note": f"{note}; gist fallback failed ({exc})"}
85
+ if response.status_code != 200:
86
+ return {"count": 0, "pastes": [], "note": f"{note}; gist search HTTP {response.status_code}"}
87
+
88
+ seen: set[str] = set()
89
+ pastes = []
90
+ for owner, gist_id in GIST_RE.findall(response.text):
91
+ if gist_id in seen:
92
+ continue
93
+ seen.add(gist_id)
94
+ pastes.append(
95
+ {
96
+ "id": gist_id,
97
+ "url": f"https://gist.github.com/{owner}/{gist_id}",
98
+ "author": owner,
99
+ }
100
+ )
101
+ if len(pastes) >= 15:
102
+ break
103
+ return {"count": len(pastes), "pastes": pastes, "source": "github-gists", "note": note}
104
+
105
+
106
+ @collector("code_mentions", ("email", "domain"), "Public code/repository mentions", order=71)
107
+ async def code_mentions(ctx: Context) -> dict:
108
+ response = await ctx.http.get(
109
+ "https://grep.app/api/search",
110
+ namespace="grep.app",
111
+ params={"q": ctx.target},
112
+ empty_on=(404, 429),
113
+ )
114
+ if response.status_code != 200:
115
+ return {"count": 0, "hits": []}
116
+ try:
117
+ body = response.json()
118
+ except ValueError:
119
+ return {"count": 0, "hits": []}
120
+ facet = body.get("facets") if isinstance(body, dict) else None
121
+ hits_raw = body.get("hits", {}).get("hits", []) if isinstance(body, dict) else []
122
+ hits = []
123
+ for hit in hits_raw[:20]:
124
+ if not isinstance(hit, dict):
125
+ continue
126
+ repo = (hit.get("repo") or {}).get("raw") if isinstance(hit.get("repo"), dict) else None
127
+ path = (hit.get("path") or {}).get("raw") if isinstance(hit.get("path"), dict) else None
128
+ snippet = ((hit.get("content") or {}).get("snippet") or "")[:400]
129
+ hits.append({"repo": repo, "path": path, "snippet": snippet})
130
+ total = None
131
+ if isinstance(facet, dict):
132
+ total = facet.get("repo", {}).get("total") if isinstance(facet.get("repo"), dict) else None
133
+ return {"count": len(hits), "total_repos": total, "hits": hits}
134
+
135
+
136
+ @collector("hackertarget_hostsearch", ("domain",), "Passive DNS host search", order=72)
137
+ async def hackertarget_hostsearch(ctx: Context) -> dict:
138
+ response = await ctx.http.get(
139
+ "https://api.hackertarget.com/hostsearch/",
140
+ namespace="hackertarget",
141
+ params={"q": ctx.target},
142
+ accept="text/plain",
143
+ empty_on=(404, 429),
144
+ )
145
+ if response.status_code != 200:
146
+ return {"hosts": []}
147
+ text = response.text.strip()
148
+ if text.lower().startswith("error") or "API count exceeded" in text:
149
+ return {"hosts": [], "note": text[:120]}
150
+ hosts = []
151
+ for line in text.splitlines():
152
+ if "," not in line:
153
+ continue
154
+ name, _, ip = line.partition(",")
155
+ hosts.append({"host": name.strip(), "ip": ip.strip()})
156
+ return {"count": len(hosts), "hosts": hosts[:100]}
157
+
158
+
159
+ @collector("hn_mentions", ("email", "username", "domain", "company"), "Hacker News mentions", order=73)
160
+ async def hn_mentions(ctx: Context) -> dict:
161
+ response = await ctx.http.get(
162
+ "https://hn.algolia.com/api/v1/search",
163
+ namespace="hn",
164
+ params={"query": ctx.target, "hitsPerPage": "20"},
165
+ empty_on=(404, 429),
166
+ timeout=ctx.http_timeout,
167
+ )
168
+ if response.status_code != 200:
169
+ return {"count": 0, "mentions": []}
170
+ try:
171
+ body = response.json()
172
+ except ValueError:
173
+ return {"count": 0, "mentions": []}
174
+ hits = body.get("hits") if isinstance(body, dict) else None
175
+ if not isinstance(hits, list):
176
+ return {"count": 0, "mentions": []}
177
+ mentions = []
178
+ for hit in hits[:20]:
179
+ if not isinstance(hit, dict):
180
+ continue
181
+ text = hit.get("comment_text") or hit.get("story_text") or ""
182
+ mentions.append(
183
+ {
184
+ "title": hit.get("title") or hit.get("story_title"),
185
+ "url": hit.get("url") or (f"https://news.ycombinator.com/item?id={hit.get('objectID')}"),
186
+ "author": hit.get("author"),
187
+ "created": hit.get("created_at"),
188
+ "snippet": snippet(text, ctx.target),
189
+ }
190
+ )
191
+ return {"count": len(mentions), "total": body.get("nbHits"), "mentions": mentions}
192
+
193
+
194
+ @collector(
195
+ "stackoverflow_mentions",
196
+ ("email", "username", "domain", "company"),
197
+ "Stack Exchange mentions",
198
+ order=74,
199
+ )
200
+ async def stackoverflow_mentions(ctx: Context) -> dict:
201
+ response = await ctx.http.get(
202
+ "https://api.stackexchange.com/2.3/search/excerpts",
203
+ namespace="stackexchange",
204
+ params={
205
+ "q": ctx.target,
206
+ "site": "stackoverflow",
207
+ "pagesize": "20",
208
+ "order": "desc",
209
+ "sort": "relevance",
210
+ },
211
+ empty_on=(400, 404, 429),
212
+ timeout=ctx.http_timeout,
213
+ )
214
+ if response.status_code != 200:
215
+ return {"count": 0, "items": []}
216
+ try:
217
+ body = response.json()
218
+ except ValueError:
219
+ return {"count": 0, "items": []}
220
+ raw = body.get("items") if isinstance(body, dict) else None
221
+ if not isinstance(raw, list):
222
+ return {"count": 0, "items": []}
223
+ items = []
224
+ for item in raw[:20]:
225
+ if not isinstance(item, dict):
226
+ continue
227
+ items.append(
228
+ {
229
+ "title": item.get("title"),
230
+ "url": item.get("question_id") and f"https://stackoverflow.com/q/{item.get('question_id')}",
231
+ "item_type": item.get("item_type"),
232
+ "score": item.get("score"),
233
+ "tags": item.get("tags"),
234
+ "excerpt": snippet(item.get("excerpt") or "", ctx.target),
235
+ }
236
+ )
237
+ return {"count": len(items), "items": items}
@@ -0,0 +1,91 @@
1
+ """Lightweight TCP connect probes against common ports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from ..http import cache_hit, cache_put
8
+ from .base import Context, collector
9
+
10
+ NAMESPACE = "ports"
11
+
12
+ PORTS: dict[int, str] = {
13
+ 21: "ftp",
14
+ 22: "ssh",
15
+ 23: "telnet",
16
+ 25: "smtp",
17
+ 53: "dns",
18
+ 80: "http",
19
+ 110: "pop3",
20
+ 143: "imap",
21
+ 443: "https",
22
+ 445: "smb",
23
+ 465: "smtps",
24
+ 587: "submission",
25
+ 993: "imaps",
26
+ 995: "pop3s",
27
+ 3306: "mysql",
28
+ 3389: "rdp",
29
+ 5432: "postgres",
30
+ 6379: "redis",
31
+ 8080: "http-alt",
32
+ 8443: "https-alt",
33
+ 9200: "elasticsearch",
34
+ 27017: "mongodb",
35
+ }
36
+
37
+ CONCURRENCY = 24
38
+ TIMEOUT = 2.5
39
+
40
+
41
+ async def _probe(host: str, port: int, service: str, semaphore: asyncio.Semaphore) -> dict | None:
42
+ async with semaphore:
43
+ try:
44
+ reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
45
+ except (TimeoutError, OSError):
46
+ return None
47
+ banner = ""
48
+ try:
49
+ banner_bytes = await asyncio.wait_for(reader.read(120), timeout=1.0)
50
+ banner = banner_bytes.decode("utf-8", "replace").strip().splitlines()[0][:120]
51
+ except (TimeoutError, OSError, IndexError):
52
+ banner = ""
53
+ finally:
54
+ writer.close()
55
+ try:
56
+ await writer.wait_closed()
57
+ except OSError:
58
+ pass
59
+ return {"port": port, "service": service, "banner": banner or None}
60
+
61
+
62
+ @collector("tcp_ports", ("domain",), "Open TCP ports on common services", order=80, ttl=3600)
63
+ def selected_ports(override: list[str] | None) -> dict[int, str]:
64
+ if not override:
65
+ return dict(PORTS)
66
+ chosen: dict[int, str] = {}
67
+ for raw in override:
68
+ port = int(raw)
69
+ chosen[port] = PORTS.get(port, "unknown")
70
+ return dict(sorted(chosen.items()))
71
+
72
+
73
+ async def tcp_ports(ctx: Context) -> dict:
74
+ ports = selected_ports(ctx.settings.extra.get("ports"))
75
+ host = ctx.target.lower()
76
+ key = f"{host}|{','.join(str(port) for port in ports)}"
77
+ cached = cache_hit(ctx.settings, NAMESPACE, key, ctx.cache_ttl)
78
+ if cached is not None:
79
+ return cached
80
+
81
+ semaphore = asyncio.Semaphore(CONCURRENCY)
82
+ results = await asyncio.gather(*(_probe(host, port, service, semaphore) for port, service in ports.items()))
83
+ open_ports = [r for r in results if r]
84
+ payload = {
85
+ "host": host,
86
+ "scanned": len(ports),
87
+ "open": sorted(open_ports, key=lambda r: r["port"]),
88
+ "note": "single connect probe from the local network; filtered ports may be missed",
89
+ }
90
+ cache_put(ctx.settings, NAMESPACE, key, payload)
91
+ return payload
@@ -0,0 +1,102 @@
1
+ """Passive subdomain enumeration: certificate logs plus passive DNS plus resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import dns.asyncresolver
8
+
9
+ from ..http import cache_hit, cache_put
10
+ from .base import Context, collector
11
+ from .ct import _query
12
+ from .pastes import hackertarget_hostsearch
13
+
14
+ NAMESPACE = "subdomains"
15
+
16
+ COMMON_PREFIXES = (
17
+ "www",
18
+ "mail",
19
+ "api",
20
+ "dev",
21
+ "staging",
22
+ "test",
23
+ "vpn",
24
+ "admin",
25
+ "cdn",
26
+ "static",
27
+ "docs",
28
+ "status",
29
+ "git",
30
+ "ci",
31
+ "app",
32
+ )
33
+
34
+
35
+ async def _resolve(name: str, resolvers: list[str]) -> str | None:
36
+ resolver = dns.asyncresolver.Resolver(configure=False)
37
+ resolver.nameservers = list(resolvers)
38
+ resolver.timeout = 3.0
39
+ resolver.lifetime = 4.0
40
+ try:
41
+ answer = await resolver.resolve(name, "A", raise_on_no_answer=False, lifetime=4.0)
42
+ except Exception:
43
+ return None
44
+ if answer.rrset is None:
45
+ return None
46
+ return str(next(iter(answer))).rstrip(".")
47
+
48
+
49
+ @collector(
50
+ "passive_subdomains",
51
+ ("domain",),
52
+ "Subdomains from cert logs, passive DNS, resolution",
53
+ order=25,
54
+ ttl=3600,
55
+ )
56
+ async def passive_subdomains(ctx: Context) -> dict:
57
+ domain = ctx.target.lower()
58
+ key = f"{domain}|{','.join(ctx.settings.resolvers)}"
59
+ cached = cache_hit(ctx.settings, NAMESPACE, key, ctx.cache_ttl)
60
+ if cached is not None:
61
+ return cached
62
+ entries = await _query(ctx, f"%.{domain}", "crt")
63
+ names: set[str] = set()
64
+
65
+ for entry in entries:
66
+ for line in f"{entry.get('name_value') or ''}\n{entry.get('common_name') or ''}".splitlines():
67
+ name = line.strip().lower().lstrip("*.").strip()
68
+ if name.endswith("." + domain) and "." in name:
69
+ names.add(name)
70
+
71
+ try:
72
+ passive = await hackertarget_hostsearch(ctx)
73
+ except Exception:
74
+ passive = {"hosts": []}
75
+ for row in passive.get("hosts") or []:
76
+ name = str(row.get("host") or "").lower()
77
+ if name.endswith("." + domain):
78
+ names.add(name)
79
+
80
+ candidates = sorted(names)
81
+ resolved = await asyncio.gather(*(_resolve(n, ctx.settings.resolvers) for n in candidates[:80]))
82
+ live = {name: ip for name, ip in zip(candidates[:80], resolved, strict=True) if ip}
83
+
84
+ unresolved_prefixes = []
85
+ for prefix in COMMON_PREFIXES:
86
+ candidate = f"{prefix}.{domain}"
87
+ if candidate in names:
88
+ continue
89
+ ip = await _resolve(candidate, ctx.settings.resolvers)
90
+ if ip:
91
+ unresolved_prefixes.append({"host": candidate, "ip": ip})
92
+
93
+ result = {
94
+ "domain": domain,
95
+ "discovered_count": len(names),
96
+ "live_hosts": [{"host": h, "ip": ip} for h, ip in sorted(live.items())],
97
+ "common_prefix_hits": unresolved_prefixes,
98
+ "sources": ["crt.sh", "hackertarget", "dns"],
99
+ "note": "passive only: no brute force beyond a short common-prefix list",
100
+ }
101
+ cache_put(ctx.settings, NAMESPACE, key, result)
102
+ return result
@@ -0,0 +1,72 @@
1
+ """WHOIS registration data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ import whois
9
+
10
+ from ..http import cache_hit, cache_put
11
+ from .base import Context, collector
12
+
13
+ NAMESPACE = "whois"
14
+ FIELD_LIMIT = 1500
15
+
16
+
17
+ def _clean(value: Any) -> Any:
18
+ if value is None:
19
+ return None
20
+ if isinstance(value, list):
21
+ return [_clean(v) for v in value[:20]]
22
+ if hasattr(value, "isoformat"):
23
+ return value.isoformat()
24
+ if isinstance(value, str):
25
+ return value.strip()[:FIELD_LIMIT]
26
+ return str(value)[:FIELD_LIMIT]
27
+
28
+
29
+ def _blocking_lookup(domain: str) -> dict:
30
+ record = whois.whois(domain)
31
+ if record is None:
32
+ return {}
33
+ data = dict(record)
34
+ keys = (
35
+ "domain_name",
36
+ "registrar",
37
+ "whois_server",
38
+ "referral_url",
39
+ "updated_date",
40
+ "creation_date",
41
+ "expiration_date",
42
+ "name_servers",
43
+ "status",
44
+ "emails",
45
+ "dnssec",
46
+ "org",
47
+ "country",
48
+ "state",
49
+ "city",
50
+ )
51
+ out: dict[str, Any] = {}
52
+ for key in keys:
53
+ if data.get(key):
54
+ out[key] = _clean(data[key])
55
+ return out
56
+
57
+
58
+ @collector("whois_registration", ("domain",), "WHOIS registration record", order=40, timeout=30)
59
+ async def whois_registration(ctx: Context) -> dict:
60
+ domain = ctx.target.lower()
61
+ cached = cache_hit(ctx.settings, NAMESPACE, domain)
62
+ if cached is not None:
63
+ return cached
64
+ try:
65
+ limit = ctx.http_timeout or 25
66
+ result = await asyncio.wait_for(asyncio.to_thread(_blocking_lookup, domain), timeout=limit)
67
+ except TimeoutError:
68
+ return {"note": "whois lookup timed out"}
69
+ except Exception as exc:
70
+ return {"note": f"whois unavailable: {type(exc).__name__}"}
71
+ cache_put(ctx.settings, NAMESPACE, domain, result)
72
+ return result