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,83 @@
|
|
|
1
|
+
"""Username correlation across public profiles that answer 404 for unknown handles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .base import Context, collector
|
|
10
|
+
|
|
11
|
+
SITES: tuple[tuple[str, str], ...] = (
|
|
12
|
+
("github", "https://github.com/{u}"),
|
|
13
|
+
("gitlab", "https://gitlab.com/{u}"),
|
|
14
|
+
("codeberg", "https://codeberg.org/{u}"),
|
|
15
|
+
("bitbucket", "https://bitbucket.org/{u}"),
|
|
16
|
+
("keybase", "https://keybase.io/{u}"),
|
|
17
|
+
("pypi", "https://pypi.org/user/{u}/"),
|
|
18
|
+
("npm", "https://www.npmjs.com/~{u}"),
|
|
19
|
+
("reddit", "https://www.reddit.com/user/{u}"),
|
|
20
|
+
("mastodon.social", "https://mastodon.social/@{u}"),
|
|
21
|
+
("dev.to", "https://dev.to/{u}"),
|
|
22
|
+
("dribbble", "https://dribbble.com/{u}"),
|
|
23
|
+
("behance", "https://www.behance.net/{u}"),
|
|
24
|
+
("kaggle", "https://www.kaggle.com/{u}"),
|
|
25
|
+
("huggingface", "https://huggingface.co/{u}"),
|
|
26
|
+
("codepen", "https://codepen.io/{u}"),
|
|
27
|
+
("twitch", "https://www.twitch.tv/{u}"),
|
|
28
|
+
("soundcloud", "https://soundcloud.com/{u}"),
|
|
29
|
+
("replit", "https://replit.com/@{u}"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
SEMAPHORE = 8
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _probe(ctx: Context, site: str, template: str) -> dict | None:
|
|
36
|
+
handle = ctx.target
|
|
37
|
+
url = template.format(u=quote(handle))
|
|
38
|
+
try:
|
|
39
|
+
status = await ctx.http.head_ok(url, namespace="presence")
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
return {"site": site, "url": url, "status": "error", "detail": type(exc).__name__}
|
|
42
|
+
if status == 404:
|
|
43
|
+
return None
|
|
44
|
+
return {
|
|
45
|
+
"site": site,
|
|
46
|
+
"url": url,
|
|
47
|
+
"status": "found" if status < 400 else "unreachable",
|
|
48
|
+
"http_status": status,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@collector("username_presence", ("username",), "Public profile presence", order=60, ttl=21600)
|
|
53
|
+
async def username_presence(ctx: Context) -> dict:
|
|
54
|
+
semaphore = asyncio.Semaphore(SEMAPHORE)
|
|
55
|
+
|
|
56
|
+
async def guarded(site: str, template: str) -> dict | None:
|
|
57
|
+
async with semaphore:
|
|
58
|
+
return await _probe(ctx, site, template)
|
|
59
|
+
|
|
60
|
+
results = await asyncio.gather(*(guarded(s, t) for s, t in SITES))
|
|
61
|
+
found = [r for r in results if r and r["status"] == "found"]
|
|
62
|
+
unknown = [r for r in results if r and r["status"] != "found"]
|
|
63
|
+
return {
|
|
64
|
+
"handle": ctx.target,
|
|
65
|
+
"checked": len(SITES),
|
|
66
|
+
"found": found,
|
|
67
|
+
"unconfirmed": unknown,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@collector("gravatar", ("email",), "Gravatar presence for the address", order=61)
|
|
72
|
+
async def gravatar(ctx: Context) -> dict:
|
|
73
|
+
digest = hashlib.md5(ctx.target.strip().lower().encode("utf-8")).hexdigest()
|
|
74
|
+
url = f"https://gravatar.com/avatar/{digest}?d=404"
|
|
75
|
+
try:
|
|
76
|
+
status = await ctx.http.head_ok(url, namespace="gravatar")
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
return {"checked": False, "detail": type(exc).__name__}
|
|
79
|
+
return {
|
|
80
|
+
"checked": True,
|
|
81
|
+
"present": status == 200,
|
|
82
|
+
"profile": f"https://gravatar.com/{digest}" if status == 200 else None,
|
|
83
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Collector registry and safe execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .. import errors
|
|
11
|
+
from ..config import Settings
|
|
12
|
+
from ..http import HttpClient
|
|
13
|
+
from ..models import CollectorResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class Context:
|
|
18
|
+
target: str
|
|
19
|
+
target_type: str
|
|
20
|
+
settings: Settings
|
|
21
|
+
http: HttpClient
|
|
22
|
+
cache_ttl: int | None = None
|
|
23
|
+
http_timeout: float | None = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def hibp_key(self) -> str | None:
|
|
27
|
+
return self.settings.hibp_api_key
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def github_token(self) -> str | None:
|
|
31
|
+
return self.settings.github_token
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
CollectorFunc = Callable[[Context], Awaitable[Any]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Collector:
|
|
39
|
+
name: str
|
|
40
|
+
target_types: tuple[str, ...]
|
|
41
|
+
func: CollectorFunc
|
|
42
|
+
description: str = ""
|
|
43
|
+
requires: str | None = None
|
|
44
|
+
order: int = 100
|
|
45
|
+
ttl: int | None = None
|
|
46
|
+
timeout: float | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
REGISTRY: list[Collector] = []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def collector(
|
|
53
|
+
name: str,
|
|
54
|
+
target_types: tuple[str, ...],
|
|
55
|
+
description: str = "",
|
|
56
|
+
*,
|
|
57
|
+
requires: str | None = None,
|
|
58
|
+
order: int = 100,
|
|
59
|
+
ttl: int | None = None,
|
|
60
|
+
timeout: float | None = None,
|
|
61
|
+
) -> Callable[[CollectorFunc], CollectorFunc]:
|
|
62
|
+
def register(func: CollectorFunc) -> CollectorFunc:
|
|
63
|
+
REGISTRY.append(
|
|
64
|
+
Collector(
|
|
65
|
+
name=name,
|
|
66
|
+
target_types=target_types,
|
|
67
|
+
func=func,
|
|
68
|
+
description=description,
|
|
69
|
+
requires=requires,
|
|
70
|
+
order=order,
|
|
71
|
+
ttl=ttl,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return func
|
|
76
|
+
|
|
77
|
+
return register
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def collectors_for(target_type: str) -> list[Collector]:
|
|
81
|
+
found = [c for c in REGISTRY if target_type in c.target_types]
|
|
82
|
+
return sorted(found, key=lambda c: (c.order, c.name))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def run_collector(spec: Collector, ctx: Context) -> CollectorResult:
|
|
86
|
+
if spec.requires and not _has_requirement(spec.requires, ctx):
|
|
87
|
+
hint = {
|
|
88
|
+
"hibp": "HIBP_API_KEY not set",
|
|
89
|
+
"github": "GITHUB_TOKEN not set",
|
|
90
|
+
}.get(spec.requires, f"{spec.requires} unavailable")
|
|
91
|
+
return CollectorResult(
|
|
92
|
+
collector=spec.name,
|
|
93
|
+
target=ctx.target,
|
|
94
|
+
target_type=ctx.target_type,
|
|
95
|
+
ok=False,
|
|
96
|
+
skipped=True,
|
|
97
|
+
note=f"skipped: {hint}",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if spec.ttl is not None or spec.timeout is not None:
|
|
101
|
+
ctx = replace(
|
|
102
|
+
ctx,
|
|
103
|
+
cache_ttl=spec.ttl if spec.ttl is not None else ctx.cache_ttl,
|
|
104
|
+
http_timeout=spec.timeout,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
started = time.perf_counter()
|
|
108
|
+
try:
|
|
109
|
+
data = await spec.func(ctx)
|
|
110
|
+
except errors.RateLimited as exc:
|
|
111
|
+
return _result(spec, ctx, started, ok=False, error=f"rate limited: {exc}")
|
|
112
|
+
except errors.SourceUnavailable as exc:
|
|
113
|
+
return _result(spec, ctx, started, ok=False, error=str(exc))
|
|
114
|
+
except TimeoutError:
|
|
115
|
+
return _result(spec, ctx, started, ok=False, error="timed out")
|
|
116
|
+
except Exception as exc: # a broken source must never sink the run
|
|
117
|
+
return _result(spec, ctx, started, ok=False, error=f"{type(exc).__name__}: {exc}")
|
|
118
|
+
|
|
119
|
+
return _result(spec, ctx, started, ok=True, data=data)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _result(spec: Collector, ctx: Context, started: float, **kwargs: Any) -> CollectorResult:
|
|
123
|
+
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
124
|
+
return CollectorResult(
|
|
125
|
+
collector=spec.name,
|
|
126
|
+
target=ctx.target,
|
|
127
|
+
target_type=ctx.target_type,
|
|
128
|
+
elapsed_ms=elapsed_ms,
|
|
129
|
+
**kwargs,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _has_requirement(requirement: str, ctx: Context) -> bool:
|
|
134
|
+
if requirement == "hibp":
|
|
135
|
+
return bool(ctx.hibp_key)
|
|
136
|
+
if requirement == "github":
|
|
137
|
+
return bool(ctx.github_token)
|
|
138
|
+
return True
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Breach exposure checks against public, login-free indexes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from .. import errors
|
|
8
|
+
from .base import Context, collector
|
|
9
|
+
|
|
10
|
+
HIBP = "https://haveibeenpwned.com/api/v3"
|
|
11
|
+
MAX_LISTED = 80
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _summarise_hibp(entries: list[dict]) -> list[dict]:
|
|
15
|
+
out = []
|
|
16
|
+
for entry in entries:
|
|
17
|
+
out.append(
|
|
18
|
+
{
|
|
19
|
+
"name": entry.get("Name") or entry.get("Title"),
|
|
20
|
+
"domain": entry.get("Domain"),
|
|
21
|
+
"date": entry.get("BreachDate"),
|
|
22
|
+
"added": entry.get("AddedDate"),
|
|
23
|
+
"pwn_count": entry.get("PwnCount"),
|
|
24
|
+
"data_types": entry.get("DataClasses") or [],
|
|
25
|
+
"verified": entry.get("IsVerified"),
|
|
26
|
+
"description": (entry.get("Description") or "")[:500],
|
|
27
|
+
"source": "haveibeenpwned",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@collector(
|
|
34
|
+
"hibp_breaches",
|
|
35
|
+
("email",),
|
|
36
|
+
"Have I Been Pwned breach membership",
|
|
37
|
+
requires="hibp",
|
|
38
|
+
order=10,
|
|
39
|
+
)
|
|
40
|
+
async def hibp_breaches(ctx: Context) -> list[dict]:
|
|
41
|
+
headers = {"hibp-api-key": ctx.hibp_key or "", "User-Agent": "osintkit/0.1"}
|
|
42
|
+
response = await ctx.http.get(
|
|
43
|
+
f"{HIBP}/breachedaccount/{quote(ctx.target)}",
|
|
44
|
+
namespace="hibp",
|
|
45
|
+
params={"truncateResponse": "false"},
|
|
46
|
+
headers=headers,
|
|
47
|
+
empty_on=(404,),
|
|
48
|
+
)
|
|
49
|
+
if response.status_code == 404:
|
|
50
|
+
return []
|
|
51
|
+
body = response.json()
|
|
52
|
+
if not isinstance(body, list):
|
|
53
|
+
return []
|
|
54
|
+
return _summarise_hibp(body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@collector(
|
|
58
|
+
"hibp_pastes",
|
|
59
|
+
("email",),
|
|
60
|
+
"Have I Been Pwned paste exposure",
|
|
61
|
+
requires="hibp",
|
|
62
|
+
order=11,
|
|
63
|
+
)
|
|
64
|
+
async def hibp_pastes(ctx: Context) -> list[dict]:
|
|
65
|
+
headers = {"hibp-api-key": ctx.hibp_key or "", "User-Agent": "osintkit/0.1"}
|
|
66
|
+
response = await ctx.http.get(
|
|
67
|
+
f"{HIBP}/pasteaccount/{quote(ctx.target)}",
|
|
68
|
+
namespace="hibp",
|
|
69
|
+
headers=headers,
|
|
70
|
+
empty_on=(404,),
|
|
71
|
+
)
|
|
72
|
+
if response.status_code == 404:
|
|
73
|
+
return []
|
|
74
|
+
body = response.json()
|
|
75
|
+
if not isinstance(body, list):
|
|
76
|
+
return []
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
"source": entry.get("Source"),
|
|
80
|
+
"id": entry.get("Id"),
|
|
81
|
+
"title": entry.get("Title"),
|
|
82
|
+
"date": entry.get("Date"),
|
|
83
|
+
"email_count": entry.get("EmailCount"),
|
|
84
|
+
"origin": "haveibeenpwned-pastes",
|
|
85
|
+
}
|
|
86
|
+
for entry in body
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@collector("xposed_or_not", ("email",), "XposedOrNot public breach lookup", order=12)
|
|
91
|
+
async def xposed_or_not(ctx: Context) -> dict:
|
|
92
|
+
response = await ctx.http.get(
|
|
93
|
+
f"https://api.xposedornot.com/v1/check-email/{quote(ctx.target)}",
|
|
94
|
+
namespace="xposedornot",
|
|
95
|
+
empty_on=(404, 422),
|
|
96
|
+
)
|
|
97
|
+
if response.status_code != 200:
|
|
98
|
+
return {"found": 0, "breaches": []}
|
|
99
|
+
try:
|
|
100
|
+
body = response.json()
|
|
101
|
+
except ValueError:
|
|
102
|
+
return {"found": 0, "breaches": []}
|
|
103
|
+
raw = body.get("breaches") or []
|
|
104
|
+
total_found = sum(len(item) if isinstance(item, list) else 1 for item in raw)
|
|
105
|
+
names: list[str] = []
|
|
106
|
+
for item in raw:
|
|
107
|
+
if isinstance(item, list):
|
|
108
|
+
names.extend(str(n) for n in item)
|
|
109
|
+
elif isinstance(item, str):
|
|
110
|
+
names.append(item)
|
|
111
|
+
details = body.get("breach_details") if isinstance(body, dict) else None
|
|
112
|
+
breaches = []
|
|
113
|
+
if isinstance(details, dict):
|
|
114
|
+
for entry in details.get("breaches") or []:
|
|
115
|
+
if not isinstance(entry, dict):
|
|
116
|
+
continue
|
|
117
|
+
breaches.append(
|
|
118
|
+
{
|
|
119
|
+
"name": entry.get("breach") or entry.get("name"),
|
|
120
|
+
"domain": entry.get("domain"),
|
|
121
|
+
"date": entry.get("xposed_date") or entry.get("breached_date"),
|
|
122
|
+
"data_types": entry.get("xposed_data") or entry.get("data_classes") or [],
|
|
123
|
+
"source": "xposedornot",
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
if not breaches:
|
|
127
|
+
breaches = [{"name": name, "source": "xposedornot"} for name in names]
|
|
128
|
+
breaches.sort(key=lambda item: str(item.get("name") or "").lower())
|
|
129
|
+
return {
|
|
130
|
+
"found": max(total_found, len(breaches)),
|
|
131
|
+
"listed": len(breaches[:MAX_LISTED]),
|
|
132
|
+
"breaches": breaches[:MAX_LISTED],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@collector("leakcheck_public", ("email",), "LeakCheck public (free tier)", order=13)
|
|
137
|
+
async def leakcheck_public(ctx: Context) -> dict:
|
|
138
|
+
response = await ctx.http.get(
|
|
139
|
+
"https://leakcheck.io/api/public",
|
|
140
|
+
namespace="leakcheck",
|
|
141
|
+
params={"check": ctx.target},
|
|
142
|
+
empty_on=(404, 400),
|
|
143
|
+
)
|
|
144
|
+
if response.status_code != 200:
|
|
145
|
+
raise errors.SourceUnavailable("leakcheck public endpoint unavailable")
|
|
146
|
+
try:
|
|
147
|
+
body = response.json()
|
|
148
|
+
except ValueError:
|
|
149
|
+
return {"found": 0, "sources": []}
|
|
150
|
+
sources = []
|
|
151
|
+
for entry in body.get("result") or []:
|
|
152
|
+
for src in entry.get("sources") or []:
|
|
153
|
+
sources.append(
|
|
154
|
+
{
|
|
155
|
+
"name": src.get("name"),
|
|
156
|
+
"date": src.get("date"),
|
|
157
|
+
"source": "leakcheck",
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
deduped: dict[str, dict] = {}
|
|
161
|
+
for src in sources:
|
|
162
|
+
deduped.setdefault(str(src.get("name")), src)
|
|
163
|
+
return {"found": len(deduped), "sources": list(deduped.values())}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Certificate transparency via crt.sh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .base import Context, collector
|
|
10
|
+
|
|
11
|
+
CRT = "https://crt.sh/"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _entries(body: object) -> list[dict]:
|
|
15
|
+
if not isinstance(body, list):
|
|
16
|
+
return []
|
|
17
|
+
return [e for e in body if isinstance(e, dict)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse(response: object) -> list[dict]:
|
|
21
|
+
"""crt.sh sometimes wraps JSON in an HTML header block."""
|
|
22
|
+
text = getattr(response, "text", "").strip()
|
|
23
|
+
if text.startswith("[BEGIN_HEADERS]"):
|
|
24
|
+
text = text.split("[END_HEADERS]", 1)[-1].replace("<BR>", "").strip()
|
|
25
|
+
if not text.startswith(("[", "{")):
|
|
26
|
+
return []
|
|
27
|
+
try:
|
|
28
|
+
return _entries(json.loads(text))
|
|
29
|
+
except ValueError:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _names(entries: list[dict], domain: str) -> list[str]:
|
|
34
|
+
seen: set[str] = set()
|
|
35
|
+
out: list[str] = []
|
|
36
|
+
for entry in entries:
|
|
37
|
+
blob = f"{entry.get('name_value') or ''}\n{entry.get('common_name') or ''}"
|
|
38
|
+
for line in blob.splitlines():
|
|
39
|
+
name = line.strip().lower().lstrip("*.").strip()
|
|
40
|
+
if not name or name == domain or "." not in name:
|
|
41
|
+
continue
|
|
42
|
+
if not name.endswith("." + domain):
|
|
43
|
+
continue
|
|
44
|
+
if name in seen:
|
|
45
|
+
continue
|
|
46
|
+
seen.add(name)
|
|
47
|
+
out.append(name)
|
|
48
|
+
return sorted(out)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _query(ctx: Context, query: str, namespace: str) -> list[dict]:
|
|
52
|
+
response = await ctx.http.get(
|
|
53
|
+
CRT,
|
|
54
|
+
namespace=namespace,
|
|
55
|
+
params={"q": query, "output": "json", "exclude": "expired"},
|
|
56
|
+
accept="application/json",
|
|
57
|
+
empty_on=(404,),
|
|
58
|
+
timeout=ctx.http_timeout,
|
|
59
|
+
)
|
|
60
|
+
if response.status_code != 200:
|
|
61
|
+
return []
|
|
62
|
+
return _parse(response)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@collector(
|
|
66
|
+
"crt_sh_subdomains",
|
|
67
|
+
("domain",),
|
|
68
|
+
"Subdomains from certificate transparency",
|
|
69
|
+
order=20,
|
|
70
|
+
timeout=90,
|
|
71
|
+
ttl=21600,
|
|
72
|
+
)
|
|
73
|
+
async def crt_sh_subdomains(ctx: Context) -> list[str]:
|
|
74
|
+
entries = await _query(ctx, f"%.{ctx.target}", "crt")
|
|
75
|
+
return _names(entries, ctx.target.lower())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@collector("crt_sh_certificates", ("domain",), "Issued certificates from crt.sh", order=21, timeout=90)
|
|
79
|
+
async def crt_sh_certificates(ctx: Context) -> list[dict]:
|
|
80
|
+
entries = await _query(ctx, f"%.{ctx.target}", "crt")
|
|
81
|
+
certs = []
|
|
82
|
+
for entry in entries[:60]:
|
|
83
|
+
issuer = str(entry.get("issuer_name") or "")
|
|
84
|
+
certs.append(
|
|
85
|
+
{
|
|
86
|
+
"id": entry.get("id"),
|
|
87
|
+
"common_name": entry.get("common_name"),
|
|
88
|
+
"names": (entry.get("name_value") or "").splitlines()[:10],
|
|
89
|
+
"issuer": issuer.split("O=")[-1].split(",")[0] if "O=" in issuer else issuer[:80],
|
|
90
|
+
"not_before": entry.get("not_before"),
|
|
91
|
+
"not_after": entry.get("not_after"),
|
|
92
|
+
"serial": entry.get("serial_number"),
|
|
93
|
+
"source": "crt.sh",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
return certs
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _company_domains(company: str) -> list[str]:
|
|
100
|
+
"""Guess likely apex domains from a company name."""
|
|
101
|
+
slug = re.sub(r"[^a-z0-9]+", "", company.lower())
|
|
102
|
+
slug = re.sub(r"(inc|llc|ltd|limited|gmbh|corp|corporation|plc)$", "", slug)
|
|
103
|
+
candidates = []
|
|
104
|
+
for suffix in (".com", ".net", ".io"):
|
|
105
|
+
candidate = slug + suffix
|
|
106
|
+
if len(slug) >= 2 and candidate not in candidates:
|
|
107
|
+
candidates.append(candidate)
|
|
108
|
+
return candidates
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _summarise(entries: list[dict]) -> dict:
|
|
112
|
+
domains: dict[str, int] = {}
|
|
113
|
+
for entry in entries:
|
|
114
|
+
for name in (entry.get("name_value") or "").splitlines():
|
|
115
|
+
name = name.strip().lower().lstrip("*.")
|
|
116
|
+
if "." in name and not name.startswith("."):
|
|
117
|
+
domains[name] = domains.get(name, 0) + 1
|
|
118
|
+
ranked = sorted(domains.items(), key=lambda kv: kv[1], reverse=True)[:50]
|
|
119
|
+
return {
|
|
120
|
+
"certificates_seen": len(entries),
|
|
121
|
+
"domains": [{"domain": d, "certificate_count": n} for d, n in ranked],
|
|
122
|
+
"sample_certificates": [
|
|
123
|
+
{
|
|
124
|
+
"common_name": e.get("common_name"),
|
|
125
|
+
"issuer": e.get("issuer_name"),
|
|
126
|
+
"not_before": e.get("not_before"),
|
|
127
|
+
}
|
|
128
|
+
for e in entries[:20]
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@collector(
|
|
134
|
+
"crt_sh_organization",
|
|
135
|
+
("company",),
|
|
136
|
+
"Certificates issued to an organisation",
|
|
137
|
+
order=20,
|
|
138
|
+
timeout=90,
|
|
139
|
+
ttl=21600,
|
|
140
|
+
)
|
|
141
|
+
async def crt_sh_organization(ctx: Context) -> dict:
|
|
142
|
+
entries = await _query(ctx, f"O={quote(ctx.target)}", "crt")
|
|
143
|
+
if entries:
|
|
144
|
+
return {**_summarise(entries), "method": "organization match"}
|
|
145
|
+
|
|
146
|
+
entries = []
|
|
147
|
+
matched: list[str] = []
|
|
148
|
+
for domain in _company_domains(ctx.target):
|
|
149
|
+
found = await _query(ctx, f"%.{domain}", "crt")
|
|
150
|
+
if found:
|
|
151
|
+
entries.extend(found)
|
|
152
|
+
matched.append(domain)
|
|
153
|
+
if len(entries) > 400:
|
|
154
|
+
break
|
|
155
|
+
if not entries:
|
|
156
|
+
return {
|
|
157
|
+
**_summarise([]),
|
|
158
|
+
"method": "no organization match",
|
|
159
|
+
"note": "crt.sh had no O= matches and the guessed domains were empty",
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
**_summarise(entries),
|
|
163
|
+
"method": "guessed domain identity",
|
|
164
|
+
"matched_domains": matched,
|
|
165
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""DNS record collection through public resolvers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import dns.asyncresolver
|
|
8
|
+
import dns.exception
|
|
9
|
+
import dns.resolver
|
|
10
|
+
|
|
11
|
+
from ..http import cache_hit, cache_put
|
|
12
|
+
from .base import Context, collector
|
|
13
|
+
|
|
14
|
+
RECORD_TYPES = ("A", "AAAA", "MX", "TXT", "NS", "CNAME")
|
|
15
|
+
NAMESPACE = "dns"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _key(ctx: Context, host: str) -> str:
|
|
19
|
+
return f"{host}|{','.join(ctx.settings.resolvers)}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolver(ctx: Context) -> dns.asyncresolver.Resolver:
|
|
23
|
+
resolver = dns.asyncresolver.Resolver(configure=False)
|
|
24
|
+
resolver.nameservers = list(ctx.settings.resolvers)
|
|
25
|
+
resolver.timeout = 4.0
|
|
26
|
+
resolver.lifetime = 6.0
|
|
27
|
+
return resolver
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format(rtype: str, record: object) -> str:
|
|
31
|
+
if rtype == "MX":
|
|
32
|
+
return f"{getattr(record, 'preference', 0)} {getattr(record, 'exchange', record)}".strip()
|
|
33
|
+
if rtype == "TXT":
|
|
34
|
+
strings = getattr(record, "strings", None)
|
|
35
|
+
if strings:
|
|
36
|
+
return b"".join(strings).decode("utf-8", "replace")
|
|
37
|
+
return str(record).strip('"')
|
|
38
|
+
return str(record).rstrip(".")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _spf_all(records: list[str]) -> str | None:
|
|
42
|
+
"""Classify the SPF `all` qualifier: -all is strict, +all is permissive."""
|
|
43
|
+
for record in records:
|
|
44
|
+
for token in record.split():
|
|
45
|
+
lowered = token.lower()
|
|
46
|
+
if lowered.endswith("all") and lowered.lstrip("~-+?").startswith("all"):
|
|
47
|
+
qualifier = lowered[:-3]
|
|
48
|
+
return f"{qualifier}all" if qualifier else "all"
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dmarc_policy(record: str) -> str | None:
|
|
53
|
+
for part in record.split(";"):
|
|
54
|
+
key, _, value = part.partition("=")
|
|
55
|
+
if key.strip().lower() == "p":
|
|
56
|
+
return value.strip().lower() or None
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _lookup(ctx: Context, host: str, rtype: str) -> list[str]:
|
|
61
|
+
resolver = _resolver(ctx)
|
|
62
|
+
try:
|
|
63
|
+
answer = await resolver.resolve(host, rtype, raise_on_no_answer=False, lifetime=6.0)
|
|
64
|
+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
|
|
65
|
+
return []
|
|
66
|
+
except dns.resolver.NoNameservers:
|
|
67
|
+
return []
|
|
68
|
+
if answer.rrset is None:
|
|
69
|
+
return []
|
|
70
|
+
return [_format(rtype, rd) for rd in answer]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _all_records(ctx: Context, host: str) -> dict[str, list[str]]:
|
|
74
|
+
results = await asyncio.gather(*(_lookup(ctx, host, rt) for rt in RECORD_TYPES))
|
|
75
|
+
return {rtype: value for rtype, value in zip(RECORD_TYPES, results, strict=True) if value}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@collector("dns_records", ("domain",), "A/AAAA/MX/TXT/NS/CNAME records", order=30, ttl=3600)
|
|
79
|
+
async def dns_records(ctx: Context) -> dict:
|
|
80
|
+
host = ctx.target.lower()
|
|
81
|
+
key = _key(ctx, host)
|
|
82
|
+
cached = cache_hit(ctx.settings, NAMESPACE, key, ctx.cache_ttl)
|
|
83
|
+
if cached is not None:
|
|
84
|
+
return cached
|
|
85
|
+
records = await _all_records(ctx, host)
|
|
86
|
+
cache_put(ctx.settings, NAMESPACE, key, records)
|
|
87
|
+
return records
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@collector("dns_mail_posture", ("domain",), "SPF, DMARC, and DKIM hints", order=31, ttl=3600)
|
|
91
|
+
async def dns_mail_posture(ctx: Context) -> dict:
|
|
92
|
+
host = ctx.target.lower()
|
|
93
|
+
key = f"mail:{_key(ctx, host)}"
|
|
94
|
+
cached = cache_hit(ctx.settings, NAMESPACE, key, ctx.cache_ttl)
|
|
95
|
+
if cached is not None:
|
|
96
|
+
return cached
|
|
97
|
+
|
|
98
|
+
txt = await _lookup(ctx, host, "TXT")
|
|
99
|
+
spf = [t for t in txt if t.lower().startswith("v=spf1")]
|
|
100
|
+
|
|
101
|
+
dmarc = await _lookup(ctx, f"_dmarc.{host}", "TXT") if not host.startswith("_dmarc.") else []
|
|
102
|
+
dmarc = [t for t in dmarc if t.lower().startswith("v=dmarc1")]
|
|
103
|
+
|
|
104
|
+
dkim_selectors = ("default", "google", "selector1", "selector2", "k1", "mail")
|
|
105
|
+
dkim_hits = []
|
|
106
|
+
for selector in dkim_selectors:
|
|
107
|
+
found = await _lookup(ctx, f"{selector}._domainkey.{host}", "TXT")
|
|
108
|
+
if found:
|
|
109
|
+
dkim_hits.append({"selector": selector, "present": True})
|
|
110
|
+
|
|
111
|
+
spf_all = _spf_all(spf)
|
|
112
|
+
result = {
|
|
113
|
+
"spf": spf,
|
|
114
|
+
"spf_present": bool(spf),
|
|
115
|
+
"spf_all": spf_all,
|
|
116
|
+
"spf_strict": spf_all == "-all",
|
|
117
|
+
"dmarc": dmarc,
|
|
118
|
+
"dmarc_present": bool(dmarc),
|
|
119
|
+
"dmarc_policy": _dmarc_policy(dmarc[0]) if dmarc else None,
|
|
120
|
+
"dkim_selectors_found": dkim_hits,
|
|
121
|
+
}
|
|
122
|
+
cache_put(ctx.settings, NAMESPACE, key, result)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@collector("dns_wildcard", ("domain",), "Wildcard DNS detection", order=32, ttl=3600)
|
|
127
|
+
async def dns_wildcard(ctx: Context) -> dict:
|
|
128
|
+
probe = f"osintkit-wildcard-probe.{ctx.target.lower()}"
|
|
129
|
+
answers = await _lookup(ctx, probe, "A")
|
|
130
|
+
return {"probe": probe, "wildcard": bool(answers), "answers": answers}
|