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
osintkit/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Runtime settings, on-disk paths, and the response cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
APP_HOME_ENV = "OSINTKIT_HOME"
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.venice.ai/api/v1"
|
|
15
|
+
DEFAULT_MODEL = "deepseek-v4-1-flash"
|
|
16
|
+
DEFAULT_TEMPERATURE = 0.3
|
|
17
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
18
|
+
DEFAULT_TIMEOUT = 30.0
|
|
19
|
+
DEFAULT_CACHE_TTL = 24 * 60 * 60
|
|
20
|
+
DEFAULT_RESOLVERS = ("1.1.1.1", "8.8.8.8", "9.9.9.9")
|
|
21
|
+
|
|
22
|
+
PROVIDERS: dict[str, tuple[str, str]] = {
|
|
23
|
+
"venice": (DEFAULT_BASE_URL, DEFAULT_MODEL),
|
|
24
|
+
"openrouter": ("https://openrouter.ai/api/v1", "deepseek/deepseek-v4.1-flash-1017"),
|
|
25
|
+
"openai": ("https://api.openai.com/v1", "gpt-4.1-mini"),
|
|
26
|
+
}
|
|
27
|
+
KEY_PREFIXES: dict[str, str] = {"sk-or-": "openrouter", "sk-proj-": "openai"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def provider_from_key(api_key: str | None) -> str | None:
|
|
31
|
+
"""Infer a provider name from a recognisable key prefix."""
|
|
32
|
+
if not api_key:
|
|
33
|
+
return None
|
|
34
|
+
for prefix, provider in KEY_PREFIXES.items():
|
|
35
|
+
if api_key.startswith(prefix):
|
|
36
|
+
return provider
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def app_home() -> Path:
|
|
41
|
+
override = os.environ.get(APP_HOME_ENV)
|
|
42
|
+
if override:
|
|
43
|
+
return Path(override).expanduser()
|
|
44
|
+
return Path.home() / ".osintkit"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cache_dir() -> Path:
|
|
48
|
+
return app_home() / "cache"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def log_dir() -> Path:
|
|
52
|
+
return app_home() / "logs"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reports_dir() -> Path:
|
|
56
|
+
return app_home() / "reports"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ensure_dirs() -> None:
|
|
60
|
+
for path in (cache_dir(), log_dir()):
|
|
61
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class Settings:
|
|
66
|
+
"""Everything the run needs, resolved once from flags and the environment."""
|
|
67
|
+
|
|
68
|
+
target: str = ""
|
|
69
|
+
target_type: str = ""
|
|
70
|
+
base_url: str = DEFAULT_BASE_URL
|
|
71
|
+
model: str = DEFAULT_MODEL
|
|
72
|
+
api_key: str | None = None
|
|
73
|
+
temperature: float = DEFAULT_TEMPERATURE
|
|
74
|
+
max_tokens: int = 2400
|
|
75
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
76
|
+
max_connections: int = 16
|
|
77
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS
|
|
78
|
+
use_cache: bool = True
|
|
79
|
+
cache_ttl: int = DEFAULT_CACHE_TTL
|
|
80
|
+
resolvers: list[str] = field(default_factory=lambda: list(DEFAULT_RESOLVERS))
|
|
81
|
+
dry_run: bool = False
|
|
82
|
+
raw: bool = False
|
|
83
|
+
output_json: bool = False
|
|
84
|
+
output_markdown: bool = False
|
|
85
|
+
verbose: bool = False
|
|
86
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def hibp_api_key(self) -> str | None:
|
|
90
|
+
return os.environ.get("HIBP_API_KEY") or None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def github_token(self) -> str | None:
|
|
94
|
+
return os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
|
|
95
|
+
|
|
96
|
+
def resolved_api_key(self) -> str | None:
|
|
97
|
+
return self.api_key or os.environ.get("API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cache_key(namespace: str, key: str) -> Path:
|
|
101
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
|
|
102
|
+
safe_ns = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in namespace)
|
|
103
|
+
return cache_dir() / safe_ns / f"{digest}.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cache_read(namespace: str, key: str, ttl: int = DEFAULT_CACHE_TTL) -> Any | None:
|
|
107
|
+
path = _cache_key(namespace, key)
|
|
108
|
+
try:
|
|
109
|
+
stat = path.stat()
|
|
110
|
+
except OSError:
|
|
111
|
+
return None
|
|
112
|
+
if ttl > 0 and time.time() - stat.st_mtime > ttl:
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
return json.loads(path.read_text("utf-8"))
|
|
116
|
+
except (OSError, json.JSONDecodeError):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cache_write(namespace: str, key: str, value: Any) -> None:
|
|
121
|
+
path = _cache_key(namespace, key)
|
|
122
|
+
try:
|
|
123
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
tmp = path.with_suffix(".tmp")
|
|
125
|
+
tmp.write_text(json.dumps(value, ensure_ascii=False, default=str), "utf-8")
|
|
126
|
+
tmp.replace(path)
|
|
127
|
+
except OSError:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cache_clear() -> int:
|
|
132
|
+
root = cache_dir()
|
|
133
|
+
if not root.exists():
|
|
134
|
+
return 0
|
|
135
|
+
removed = 0
|
|
136
|
+
for path in root.rglob("*.json"):
|
|
137
|
+
try:
|
|
138
|
+
path.unlink()
|
|
139
|
+
removed += 1
|
|
140
|
+
except OSError:
|
|
141
|
+
continue
|
|
142
|
+
return removed
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cache_stats() -> tuple[int, int]:
|
|
146
|
+
root = cache_dir()
|
|
147
|
+
if not root.exists():
|
|
148
|
+
return (0, 0)
|
|
149
|
+
files = list(root.rglob("*.json"))
|
|
150
|
+
size = sum(p.stat().st_size for p in files if p.exists())
|
|
151
|
+
return (len(files), size)
|
osintkit/diff.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Compare two archived reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .models import Report
|
|
8
|
+
|
|
9
|
+
SEVERITY = ("low", "medium", "high")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _names(rows: list[dict[str, Any]], field: str = "name") -> dict[str, dict[str, Any]]:
|
|
13
|
+
out: dict[str, dict[str, Any]] = {}
|
|
14
|
+
for row in rows:
|
|
15
|
+
key = str(row.get(field) or "").strip().lower()
|
|
16
|
+
if key:
|
|
17
|
+
out[key] = row
|
|
18
|
+
return out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _set_diff(old: list[str], new: list[str]) -> dict[str, list[str]]:
|
|
22
|
+
before = {item.strip().lower(): item.strip() for item in old if item.strip()}
|
|
23
|
+
after = {item.strip().lower(): item.strip() for item in new if item.strip()}
|
|
24
|
+
return {
|
|
25
|
+
"added": sorted(after[key] for key in after.keys() - before.keys()),
|
|
26
|
+
"removed": sorted(before[key] for key in before.keys() - after.keys()),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compare(old: Report, new: Report) -> dict[str, Any]:
|
|
31
|
+
old_analysis = old.analysis
|
|
32
|
+
new_analysis = new.analysis
|
|
33
|
+
old_breaches = _names([b.model_dump() for b in old_analysis.breaches]) if old_analysis else {}
|
|
34
|
+
new_breaches = _names([b.model_dump() for b in new_analysis.breaches]) if new_analysis else {}
|
|
35
|
+
|
|
36
|
+
delta = _risk_delta(old, new)
|
|
37
|
+
old_status = {c.collector: (c.skipped, c.ok) for c in old.collectors}
|
|
38
|
+
new_status = {c.collector: (c.skipped, c.ok) for c in new.collectors}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"old_target": old.target,
|
|
42
|
+
"new_target": new.target,
|
|
43
|
+
"target_changed": old.target != new.target,
|
|
44
|
+
"old_generated": old.generated_at,
|
|
45
|
+
"new_generated": new.generated_at,
|
|
46
|
+
"risk": {
|
|
47
|
+
"old": old_analysis.risk_level if old_analysis else None,
|
|
48
|
+
"new": new_analysis.risk_level if new_analysis else None,
|
|
49
|
+
},
|
|
50
|
+
"breaches": {
|
|
51
|
+
"added": sorted(new_breaches[k]["name"] for k in new_breaches.keys() - old_breaches.keys()),
|
|
52
|
+
"removed": sorted(old_breaches[k]["name"] for k in old_breaches.keys() - new_breaches.keys()),
|
|
53
|
+
"unchanged": len(old_breaches.keys() & new_breaches.keys()),
|
|
54
|
+
},
|
|
55
|
+
"subdomains": _set_diff(
|
|
56
|
+
old_analysis.subdomains if old_analysis else [],
|
|
57
|
+
new_analysis.subdomains if new_analysis else [],
|
|
58
|
+
),
|
|
59
|
+
"collectors": {
|
|
60
|
+
"started_working": sorted(
|
|
61
|
+
name
|
|
62
|
+
for name, state in new_status.items()
|
|
63
|
+
if state[1] and not state[0] and old_status.get(name, (False, False))[1] is False
|
|
64
|
+
),
|
|
65
|
+
"started_failing": sorted(
|
|
66
|
+
name
|
|
67
|
+
for name, state in new_status.items()
|
|
68
|
+
if not state[1] and not state[0] and old_status.get(name, (False, True))[1] is True
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
"risk_rose": delta > 0,
|
|
72
|
+
"risk_fell": delta < 0,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _risk_delta(old: Report, new: Report) -> int:
|
|
77
|
+
if not old.analysis or not new.analysis:
|
|
78
|
+
return 0
|
|
79
|
+
before = SEVERITY.index(old.analysis.risk_level) if old.analysis.risk_level in SEVERITY else 0
|
|
80
|
+
after = SEVERITY.index(new.analysis.risk_level) if new.analysis.risk_level in SEVERITY else 0
|
|
81
|
+
return after - before
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_empty(diff: dict[str, Any]) -> bool:
|
|
85
|
+
return not any(
|
|
86
|
+
(
|
|
87
|
+
diff["breaches"]["added"],
|
|
88
|
+
diff["breaches"]["removed"],
|
|
89
|
+
diff["subdomains"]["added"],
|
|
90
|
+
diff["subdomains"]["removed"],
|
|
91
|
+
diff["collectors"]["started_working"],
|
|
92
|
+
diff["collectors"]["started_failing"],
|
|
93
|
+
diff["risk_rose"],
|
|
94
|
+
diff["risk_fell"],
|
|
95
|
+
)
|
|
96
|
+
)
|
osintkit/doctor.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Environment checks behind `osintkit doctor`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import Settings, cache_dir, ensure_dirs, log_dir
|
|
11
|
+
from .http import HttpClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class Check:
|
|
16
|
+
name: str
|
|
17
|
+
status: str
|
|
18
|
+
detail: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _writable(path: Path) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
ensure_dirs()
|
|
24
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with tempfile.NamedTemporaryFile(dir=path, delete=True):
|
|
26
|
+
return True
|
|
27
|
+
except OSError:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _reach_endpoint(settings: Settings, deep: bool = False) -> Check:
|
|
32
|
+
key = settings.resolved_api_key()
|
|
33
|
+
if not key:
|
|
34
|
+
return Check("model endpoint", "warn", "no API key; synthesis will fail, dry runs still work")
|
|
35
|
+
base = settings.base_url.rstrip("/")
|
|
36
|
+
headers = {"Authorization": f"Bearer {key}"}
|
|
37
|
+
fresh = replace(settings, use_cache=False)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
async with HttpClient(fresh) as http:
|
|
41
|
+
response = await http.get(
|
|
42
|
+
f"{base}/models",
|
|
43
|
+
namespace="doctor",
|
|
44
|
+
headers=headers,
|
|
45
|
+
empty_on=tuple(range(400, 500)),
|
|
46
|
+
timeout=10.0,
|
|
47
|
+
)
|
|
48
|
+
if response.status_code >= 400 and response.status_code != 404:
|
|
49
|
+
return Check("model endpoint", "fail", f"HTTP {response.status_code} from {base}/models")
|
|
50
|
+
|
|
51
|
+
if not deep:
|
|
52
|
+
detail = (
|
|
53
|
+
f"{base} answered; key not exercised, use --deep"
|
|
54
|
+
if response.status_code < 400
|
|
55
|
+
else f"HTTP {response.status_code} from {base}/models; use --deep"
|
|
56
|
+
)
|
|
57
|
+
return Check("model endpoint", "ok" if response.status_code < 400 else "warn", detail)
|
|
58
|
+
|
|
59
|
+
probe = await http.post_json(
|
|
60
|
+
f"{base}/chat/completions",
|
|
61
|
+
namespace="doctor",
|
|
62
|
+
headers=headers,
|
|
63
|
+
payload={
|
|
64
|
+
"model": settings.model,
|
|
65
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
66
|
+
"max_tokens": 1,
|
|
67
|
+
"temperature": 0,
|
|
68
|
+
},
|
|
69
|
+
timeout=20.0,
|
|
70
|
+
empty_on=tuple(range(400, 500)),
|
|
71
|
+
)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
return Check("model endpoint", "fail", f"{base}: {type(exc).__name__}")
|
|
74
|
+
|
|
75
|
+
if probe.status_code in (401, 403):
|
|
76
|
+
return Check("model endpoint", "fail", f"key rejected (HTTP {probe.status_code})")
|
|
77
|
+
if probe.status_code == 404:
|
|
78
|
+
return Check("model endpoint", "fail", f"model {settings.model!r} or route not found (404)")
|
|
79
|
+
if probe.status_code >= 400:
|
|
80
|
+
return Check("model endpoint", "fail", f"completion probe failed (HTTP {probe.status_code})")
|
|
81
|
+
return Check("model endpoint", "ok", f"{base} accepted a completion for {settings.model}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def collect_checks(settings: Settings) -> list[Check]:
|
|
85
|
+
key = settings.resolved_api_key()
|
|
86
|
+
checks = [
|
|
87
|
+
Check(
|
|
88
|
+
"api key",
|
|
89
|
+
"ok" if key else "warn",
|
|
90
|
+
"set" if key else "API_KEY is not set",
|
|
91
|
+
),
|
|
92
|
+
Check(
|
|
93
|
+
"cache dir",
|
|
94
|
+
"ok" if _writable(cache_dir()) else "fail",
|
|
95
|
+
str(cache_dir()),
|
|
96
|
+
),
|
|
97
|
+
Check(
|
|
98
|
+
"log dir",
|
|
99
|
+
"ok" if _writable(log_dir()) else "fail",
|
|
100
|
+
str(log_dir()),
|
|
101
|
+
),
|
|
102
|
+
]
|
|
103
|
+
for name, variable, why in (
|
|
104
|
+
("Have I Been Pwned", "HIBP_API_KEY", "breach collectors will be skipped"),
|
|
105
|
+
("GitHub", "GITHUB_TOKEN", "GitHub is limited to 60 requests/hour"),
|
|
106
|
+
):
|
|
107
|
+
present = bool(os.environ.get(variable))
|
|
108
|
+
checks.append(Check(name, "ok" if present else "warn", "set" if present else why))
|
|
109
|
+
if key:
|
|
110
|
+
checks.append(Check("api key shape", "ok" if len(key) >= 20 else "warn", f"{len(key)} chars"))
|
|
111
|
+
return checks
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def diagnose(settings: Settings, *, deep: bool = False) -> list[Check]:
|
|
115
|
+
return [*collect_checks(settings), await _reach_endpoint(settings, deep)]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def failed(checks: list[Check]) -> bool:
|
|
119
|
+
return any(check.status == "fail" for check in checks)
|
osintkit/errors.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Errors and shared type aliases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
TargetType = Literal["email", "username", "domain", "company"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OsintKitError(Exception):
|
|
11
|
+
"""Base error for osintkit."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CollectorError(OsintKitError):
|
|
15
|
+
"""A collector could not complete its work."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimited(OsintKitError):
|
|
19
|
+
"""An upstream source asked us to slow down."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, retry_after: float | None = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.retry_after = retry_after
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SourceUnavailable(OsintKitError):
|
|
27
|
+
"""An upstream source is down or blocked."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SynthesisError(OsintKitError):
|
|
31
|
+
"""The model returned something we could not validate."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MissingAPIKey(OsintKitError):
|
|
35
|
+
"""No API key was supplied and none was found in the environment."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TargetParseError(OsintKitError):
|
|
39
|
+
"""A target string could not be typed."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"CollectorError",
|
|
44
|
+
"MissingAPIKey",
|
|
45
|
+
"OsintKitError",
|
|
46
|
+
"RateLimited",
|
|
47
|
+
"SourceUnavailable",
|
|
48
|
+
"SynthesisError",
|
|
49
|
+
"TargetParseError",
|
|
50
|
+
"TargetType",
|
|
51
|
+
]
|
osintkit/http.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Resilient HTTP client: retries, exponential backoff, and a warm cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .config import Settings, cache_read, cache_write
|
|
14
|
+
from .errors import RateLimited, SourceUnavailable
|
|
15
|
+
|
|
16
|
+
USER_AGENT = "osintkit/0.1 (+public-data-osint; contact: local user)"
|
|
17
|
+
RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HttpClient:
|
|
21
|
+
"""Thin async wrapper with bounded retries and transparent caching.
|
|
22
|
+
|
|
23
|
+
Only GETs are needed by the collectors, so that is all this exposes.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
|
27
|
+
self.settings = settings
|
|
28
|
+
self._inflight: dict[tuple[str, str], asyncio.Task[httpx.Response]] = {}
|
|
29
|
+
self._own_client = client is None
|
|
30
|
+
self._client = client or httpx.AsyncClient(
|
|
31
|
+
timeout=httpx.Timeout(settings.timeout),
|
|
32
|
+
limits=httpx.Limits(max_connections=settings.max_connections),
|
|
33
|
+
follow_redirects=True,
|
|
34
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def __aenter__(self) -> Self:
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
41
|
+
await self.aclose()
|
|
42
|
+
|
|
43
|
+
async def aclose(self) -> None:
|
|
44
|
+
if self._own_client:
|
|
45
|
+
await self._client.aclose()
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _cache_key(url: str, params: Mapping[str, Any] | None) -> str:
|
|
49
|
+
if not params:
|
|
50
|
+
return url
|
|
51
|
+
return f"{url}?{json.dumps(params, sort_keys=True, default=str)}"
|
|
52
|
+
|
|
53
|
+
async def get(
|
|
54
|
+
self,
|
|
55
|
+
url: str,
|
|
56
|
+
*,
|
|
57
|
+
namespace: str,
|
|
58
|
+
params: Mapping[str, Any] | None = None,
|
|
59
|
+
headers: Mapping[str, str] | None = None,
|
|
60
|
+
accept: str = "application/json",
|
|
61
|
+
empty_on: Iterable[int] = (404,),
|
|
62
|
+
timeout: float | None = None,
|
|
63
|
+
) -> httpx.Response:
|
|
64
|
+
return await self._request(
|
|
65
|
+
"GET",
|
|
66
|
+
url,
|
|
67
|
+
namespace=namespace,
|
|
68
|
+
params=params,
|
|
69
|
+
headers=headers,
|
|
70
|
+
accept=accept,
|
|
71
|
+
empty_on=empty_on,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def post_json(
|
|
76
|
+
self,
|
|
77
|
+
url: str,
|
|
78
|
+
*,
|
|
79
|
+
namespace: str,
|
|
80
|
+
payload: Mapping[str, Any],
|
|
81
|
+
headers: Mapping[str, str] | None = None,
|
|
82
|
+
timeout: float | None = None,
|
|
83
|
+
empty_on: Iterable[int] = (),
|
|
84
|
+
) -> httpx.Response:
|
|
85
|
+
return await self._request(
|
|
86
|
+
"POST",
|
|
87
|
+
url,
|
|
88
|
+
namespace=namespace,
|
|
89
|
+
payload=payload,
|
|
90
|
+
headers=headers,
|
|
91
|
+
empty_on=empty_on,
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def _request(
|
|
96
|
+
self,
|
|
97
|
+
method: str,
|
|
98
|
+
url: str,
|
|
99
|
+
*,
|
|
100
|
+
namespace: str,
|
|
101
|
+
params: Mapping[str, Any] | None = None,
|
|
102
|
+
payload: Mapping[str, Any] | None = None,
|
|
103
|
+
headers: Mapping[str, str] | None = None,
|
|
104
|
+
accept: str = "application/json",
|
|
105
|
+
empty_on: Iterable[int] = (),
|
|
106
|
+
timeout: float | None = None,
|
|
107
|
+
) -> httpx.Response:
|
|
108
|
+
key = self._cache_key(url, payload if method != "GET" else params)
|
|
109
|
+
if self.settings.use_cache:
|
|
110
|
+
cached = cache_read(namespace, key, self.settings.cache_ttl)
|
|
111
|
+
if cached is not None:
|
|
112
|
+
return httpx.Response(
|
|
113
|
+
status_code=cached["status"],
|
|
114
|
+
headers=cached.get("headers", {}),
|
|
115
|
+
content=cached.get("body", "").encode("utf-8"),
|
|
116
|
+
request=httpx.Request(method, url),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
slot = (namespace, key)
|
|
120
|
+
pending = self._inflight.get(slot)
|
|
121
|
+
if pending is not None and not pending.done():
|
|
122
|
+
return await asyncio.shield(pending)
|
|
123
|
+
|
|
124
|
+
task = asyncio.ensure_future(
|
|
125
|
+
self._perform(
|
|
126
|
+
method,
|
|
127
|
+
url,
|
|
128
|
+
namespace=namespace,
|
|
129
|
+
key=key,
|
|
130
|
+
params=params,
|
|
131
|
+
payload=payload,
|
|
132
|
+
headers=headers,
|
|
133
|
+
accept=accept,
|
|
134
|
+
empty_on=empty_on,
|
|
135
|
+
timeout=timeout,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
self._inflight[slot] = task
|
|
139
|
+
try:
|
|
140
|
+
return await task
|
|
141
|
+
finally:
|
|
142
|
+
if self._inflight.get(slot) is task:
|
|
143
|
+
self._inflight.pop(slot, None)
|
|
144
|
+
|
|
145
|
+
async def _perform(
|
|
146
|
+
self,
|
|
147
|
+
method: str,
|
|
148
|
+
url: str,
|
|
149
|
+
*,
|
|
150
|
+
namespace: str,
|
|
151
|
+
key: str,
|
|
152
|
+
params: Mapping[str, Any] | None = None,
|
|
153
|
+
payload: Mapping[str, Any] | None = None,
|
|
154
|
+
headers: Mapping[str, str] | None = None,
|
|
155
|
+
accept: str = "application/json",
|
|
156
|
+
empty_on: Iterable[int] = (),
|
|
157
|
+
timeout: float | None = None,
|
|
158
|
+
) -> httpx.Response:
|
|
159
|
+
last_error: Exception | None = None
|
|
160
|
+
for attempt in range(1, self.settings.max_attempts + 1):
|
|
161
|
+
try:
|
|
162
|
+
request_headers = {"Accept": accept}
|
|
163
|
+
if headers:
|
|
164
|
+
request_headers.update(headers)
|
|
165
|
+
if method == "GET":
|
|
166
|
+
response = await self._client.get(
|
|
167
|
+
url, params=params, headers=request_headers, timeout=timeout
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
response = await self._client.post(
|
|
171
|
+
url, json=dict(payload or {}), headers=request_headers, timeout=timeout
|
|
172
|
+
)
|
|
173
|
+
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
|
174
|
+
last_error = exc
|
|
175
|
+
if attempt >= self.settings.max_attempts:
|
|
176
|
+
raise SourceUnavailable(f"{url}: {type(exc).__name__}") from exc
|
|
177
|
+
await self._sleep(attempt, None)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if response.status_code in empty_on:
|
|
181
|
+
self._store(namespace, key, response)
|
|
182
|
+
return response
|
|
183
|
+
|
|
184
|
+
if response.status_code in RETRY_STATUS:
|
|
185
|
+
retry_after = _retry_after(response)
|
|
186
|
+
if attempt >= self.settings.max_attempts:
|
|
187
|
+
raise RateLimited(
|
|
188
|
+
f"{url}: HTTP {response.status_code} after {attempt} attempts",
|
|
189
|
+
retry_after=retry_after,
|
|
190
|
+
)
|
|
191
|
+
await self._sleep(attempt, retry_after)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
if response.status_code == 403 and _quota_exhausted(response):
|
|
195
|
+
if attempt >= self.settings.max_attempts:
|
|
196
|
+
raise RateLimited(f"{url}: quota exhausted after {attempt} attempts")
|
|
197
|
+
await self._sleep(attempt, _retry_after(response))
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if response.status_code >= 400:
|
|
201
|
+
raise SourceUnavailable(f"{url}: HTTP {response.status_code}")
|
|
202
|
+
|
|
203
|
+
self._store(namespace, key, response)
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
raise SourceUnavailable(f"{url}: {last_error}")
|
|
207
|
+
|
|
208
|
+
async def get_json(self, url: str, **kwargs: Any) -> Any:
|
|
209
|
+
response = await self.get(url, **kwargs)
|
|
210
|
+
if response.status_code in set(kwargs.get("empty_on") or (404,)):
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
return response.json()
|
|
214
|
+
except (json.JSONDecodeError, ValueError) as exc: # pragma: no cover - malformed upstream
|
|
215
|
+
raise SourceUnavailable(f"{url}: invalid JSON") from exc
|
|
216
|
+
|
|
217
|
+
async def head_ok(self, url: str, *, namespace: str = "presence", **kwargs: Any) -> int:
|
|
218
|
+
"""Return the final status code, treating any HTTP answer as a hit."""
|
|
219
|
+
response = await self.get(url, namespace=namespace, empty_on=tuple(range(400, 500)), **kwargs)
|
|
220
|
+
return response.status_code
|
|
221
|
+
|
|
222
|
+
def _store(self, namespace: str, key: str, response: httpx.Response) -> None:
|
|
223
|
+
if not self.settings.use_cache:
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
body = response.text
|
|
227
|
+
except Exception: # pragma: no cover - undecodable body
|
|
228
|
+
body = ""
|
|
229
|
+
headers = {
|
|
230
|
+
name: value
|
|
231
|
+
for name, value in response.headers.items()
|
|
232
|
+
if name.lower() not in {"content-encoding", "content-length", "transfer-encoding"}
|
|
233
|
+
}
|
|
234
|
+
cache_write(
|
|
235
|
+
namespace,
|
|
236
|
+
key,
|
|
237
|
+
{"status": response.status_code, "headers": headers, "body": body},
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def _sleep(self, attempt: int, retry_after: float | None) -> None:
|
|
241
|
+
if retry_after:
|
|
242
|
+
delay = min(retry_after, 30.0)
|
|
243
|
+
else:
|
|
244
|
+
delay = min(0.5 * (2 ** (attempt - 1)), 8.0)
|
|
245
|
+
delay += random.uniform(0, delay / 2)
|
|
246
|
+
await asyncio.sleep(delay)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _quota_exhausted(response: httpx.Response) -> bool:
|
|
250
|
+
return response.headers.get("x-ratelimit-remaining") == "0"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
254
|
+
raw = response.headers.get("retry-after")
|
|
255
|
+
if not raw:
|
|
256
|
+
return None
|
|
257
|
+
try:
|
|
258
|
+
return float(raw)
|
|
259
|
+
except ValueError:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cache_hit(settings: Settings, namespace: str, key: str, ttl: int | None = None) -> Any | None:
|
|
264
|
+
"""Read a cached non-HTTP collector result (DNS, WHOIS, TCP probes)."""
|
|
265
|
+
if not settings.use_cache:
|
|
266
|
+
return None
|
|
267
|
+
return cache_read(namespace, key, settings.cache_ttl if ttl is None else ttl)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cache_put(settings: Settings, namespace: str, key: str, value: Any) -> None:
|
|
271
|
+
if settings.use_cache:
|
|
272
|
+
cache_write(namespace, key, value)
|