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.
- osintkit/__init__.py +3 -0
- osintkit/cli.py +673 -0
- osintkit/collectors/__init__.py +24 -0
- osintkit/collectors/accounts.py +83 -0
- osintkit/collectors/base.py +138 -0
- osintkit/collectors/breaches.py +163 -0
- osintkit/collectors/ct.py +165 -0
- osintkit/collectors/dns.py +130 -0
- osintkit/collectors/github.py +190 -0
- osintkit/collectors/pastes.py +237 -0
- osintkit/collectors/ports.py +91 -0
- osintkit/collectors/subdomains.py +102 -0
- osintkit/collectors/whois_lookup.py +72 -0
- osintkit/config.py +151 -0
- osintkit/diff.py +96 -0
- osintkit/doctor.py +119 -0
- osintkit/errors.py +51 -0
- osintkit/http.py +272 -0
- osintkit/llm.py +91 -0
- osintkit/logging_store.py +206 -0
- osintkit/models.py +228 -0
- osintkit/normalise.py +131 -0
- osintkit/pipeline.py +237 -0
- osintkit/py.typed +0 -0
- osintkit/render.py +235 -0
- osintkit/retention.py +81 -0
- osintkit/synthesis.py +176 -0
- osintkit/targets.py +65 -0
- osintkit-0.1.1.dist-info/METADATA +176 -0
- osintkit-0.1.1.dist-info/RECORD +33 -0
- osintkit-0.1.1.dist-info/WHEEL +4 -0
- osintkit-0.1.1.dist-info/entry_points.txt +2 -0
- osintkit-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|