osintengine 1.0.2__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.
- osintengine-1.0.2.dist-info/METADATA +311 -0
- osintengine-1.0.2.dist-info/RECORD +103 -0
- osintengine-1.0.2.dist-info/WHEEL +5 -0
- osintengine-1.0.2.dist-info/entry_points.txt +2 -0
- osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
- osintengine-1.0.2.dist-info/top_level.txt +2 -0
- src/watson/__init__.py +10 -0
- src/watson/agent/__init__.py +96 -0
- src/watson/agents/__init__.py +101 -0
- src/watson/agents/protocol.py +196 -0
- src/watson/auth/__init__.py +68 -0
- src/watson/auth/store.py +145 -0
- src/watson/conversation.py +68 -0
- src/watson/core/__init__.py +0 -0
- src/watson/core/models.py +96 -0
- src/watson/ethics.py +205 -0
- src/watson/exports.py +182 -0
- src/watson/graph/__init__.py +69 -0
- src/watson/graph/entities.py +207 -0
- src/watson/graph/graph.py +489 -0
- src/watson/graph/osint_framework.py +266 -0
- src/watson/graph/relationships.py +116 -0
- src/watson/graph/transforms.py +787 -0
- src/watson/infra/__init__.py +43 -0
- src/watson/infra/cache.py +171 -0
- src/watson/infra/ratelimit.py +125 -0
- src/watson/infra/resilience.py +239 -0
- src/watson/infra/retry.py +186 -0
- src/watson/metrics.py +128 -0
- src/watson/opsec/__init__.py +290 -0
- src/watson/orchestration/__init__.py +23 -0
- src/watson/orchestration/engine.py +5587 -0
- src/watson/orchestration/executor.py +96 -0
- src/watson/orchestration/intent.py +139 -0
- src/watson/orchestration/intent_classifier.py +45 -0
- src/watson/orchestration/llm_config.py +160 -0
- src/watson/orchestration/resolution.py +555 -0
- src/watson/orchestration/scraper.py +94 -0
- src/watson/orchestration/synthesis.py +726 -0
- src/watson/orchestration/target_profile.py +748 -0
- src/watson/persistence/__init__.py +18 -0
- src/watson/persistence/models.py +161 -0
- src/watson/persistence/store.py +230 -0
- src/watson/pipeline/__init__.py +16 -0
- src/watson/pipeline/pre_synthesis.py +621 -0
- src/watson/search.py +103 -0
- src/watson/serializers/stix.py +451 -0
- src/watson/tools/__init__.py +31 -0
- src/watson/tools/base.py +97 -0
- src/watson/tools/blockchain.py +359 -0
- src/watson/tools/captcha.py +276 -0
- src/watson/tools/conflict.py +107 -0
- src/watson/tools/corporate.py +287 -0
- src/watson/tools/darkweb.py +144 -0
- src/watson/tools/geolocation.py +347 -0
- src/watson/tools/image_video.py +108 -0
- src/watson/tools/marinetraffic.py +142 -0
- src/watson/tools/people.py +476 -0
- src/watson/tools/registry.py +56 -0
- src/watson/tools/satellite.py +118 -0
- src/watson/tools/scraper.py +745 -0
- src/watson/tools/shodan.py +129 -0
- src/watson/tools/social_media.py +160 -0
- src/watson/tools/websites.py +291 -0
- src/watson/tools/wikidata.py +398 -0
- src/watson/utils/__init__.py +1 -0
- src/watson/utils/helpers.py +49 -0
- src/watson/utils/http.py +119 -0
- src/watson/verification/__init__.py +245 -0
- watson/__init__.py +6 -0
- watson/agents/__init__.py +20 -0
- watson/agents/base.py +103 -0
- watson/agents/direct.py +225 -0
- watson/agents/hermes.py +275 -0
- watson/agents/hermes_mcp.py +292 -0
- watson/api_keys.py +176 -0
- watson/browser_scraper.py +481 -0
- watson/cli.py +836 -0
- watson/crossref.py +175 -0
- watson/ethics.py +20 -0
- watson/graph.py +406 -0
- watson/mcp_server.py +601 -0
- watson/memory.py +552 -0
- watson/neo4j_graph.py +124 -0
- watson/opsec/__init__.py +307 -0
- watson/reporter.py +537 -0
- watson/scheduler.py +486 -0
- watson/serializers/stix.py +451 -0
- watson/terminal.py +410 -0
- watson/toolkit.py +252 -0
- watson/toolkit_api.py +347 -0
- watson/toolkit_automation.py +95 -0
- watson/toolkit_registry.py +345 -0
- watson/verification/__init__.py +251 -0
- watson/web/__init__.py +1 -0
- watson/web/app.py +1650 -0
- watson/web/middleware.py +301 -0
- watson/web/static/assets/index-B7hPOc0z.js +278 -0
- watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
- watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
- watson/web/static/index.html +14 -0
- watson/web/templates/chat.html +2 -0
- watson/web/templates/investigation-map.html +429 -0
watson/opsec/__init__.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""OpSec Proxy Firewall — enterprise HTTP layer with proxy chaining, domain-
|
|
2
|
+
specific rate limiting, and connection pooling.
|
|
3
|
+
|
|
4
|
+
Extends BaseHTTPClient from watson.utils.http with:
|
|
5
|
+
- SOCKS5 / HTTP / HTTPS proxy support
|
|
6
|
+
- Per-domain rate limiting (token bucket per host)
|
|
7
|
+
- Configurable proxy chain (multiple hops)
|
|
8
|
+
- TLS fingerprint rotation
|
|
9
|
+
- Real browser header profiles per domain category
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
from typing import Optional
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
# Import BaseHTTPClient from either package root (watson/) or src tree (src/watson/)
|
|
25
|
+
try:
|
|
26
|
+
from .utils.http import BaseHTTPClient
|
|
27
|
+
except ImportError:
|
|
28
|
+
try:
|
|
29
|
+
from src.watson.utils.http import BaseHTTPClient
|
|
30
|
+
except ImportError:
|
|
31
|
+
BaseHTTPClient = None # type: ignore
|
|
32
|
+
|
|
33
|
+
USER_AGENTS = [
|
|
34
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
35
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
36
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
37
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
|
|
38
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
|
|
39
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
|
|
40
|
+
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("watson.opsec")
|
|
44
|
+
|
|
45
|
+
# ── Proxy configuration ──────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
_DEFAULT_PROXY = os.environ.get("WATSON_PROXY", "") # socks5://user:pass@host:port
|
|
48
|
+
_PROXY_CHAIN = os.environ.get("WATSON_PROXY_CHAIN", "") # comma-separated SOCKS5 URLs
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_proxy_url(url: str) -> str | None:
|
|
52
|
+
"""Parse a proxy URL into httpx format. Returns None if empty/invalid."""
|
|
53
|
+
if not url or not url.strip():
|
|
54
|
+
return None
|
|
55
|
+
parsed = urlparse(url.strip())
|
|
56
|
+
if parsed.scheme in ("socks5", "socks5h"):
|
|
57
|
+
# httpx: socks5://user:pass@host:port
|
|
58
|
+
netloc = parsed.hostname or ""
|
|
59
|
+
if parsed.port:
|
|
60
|
+
netloc += f":{parsed.port}"
|
|
61
|
+
auth = f"{parsed.username}:{parsed.password}@" if parsed.username else ""
|
|
62
|
+
return f"socks5://{auth}{netloc}"
|
|
63
|
+
if parsed.scheme in ("http", "https"):
|
|
64
|
+
return url.strip()
|
|
65
|
+
logger.warning("opsec: unknown proxy scheme %s", parsed.scheme)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Per-domain rate limiter ───────────────────────────────────
|
|
70
|
+
|
|
71
|
+
class DomainRateLimiter:
|
|
72
|
+
"""Token bucket rate limiter per domain."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, default_rps: float = 1.0):
|
|
75
|
+
self.default_rps = default_rps
|
|
76
|
+
self._buckets: dict[str, tuple[float, float]] = {} # domain → (tokens, last_refill)
|
|
77
|
+
# Harsher limits for known rate-limit-happy domains
|
|
78
|
+
self._overrides: dict[str, float] = {
|
|
79
|
+
"crt.sh": 0.5,
|
|
80
|
+
"opencorporates.com": 0.5,
|
|
81
|
+
"google.com": 0.3,
|
|
82
|
+
"linkedin.com": 0.2,
|
|
83
|
+
"api.github.com": 2.0,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def _rps_for(self, domain: str) -> float:
|
|
87
|
+
for pattern, rps in self._overrides.items():
|
|
88
|
+
if pattern in domain:
|
|
89
|
+
return rps
|
|
90
|
+
return self.default_rps
|
|
91
|
+
|
|
92
|
+
async def acquire(self, domain: str) -> None:
|
|
93
|
+
rate = self._rps_for(domain)
|
|
94
|
+
while True:
|
|
95
|
+
now = time.monotonic()
|
|
96
|
+
tokens, last = self._buckets.get(domain, (rate, now))
|
|
97
|
+
elapsed = now - last
|
|
98
|
+
tokens = min(rate, tokens + elapsed * rate)
|
|
99
|
+
self._buckets[domain] = (tokens, now)
|
|
100
|
+
|
|
101
|
+
if tokens >= 1.0:
|
|
102
|
+
self._buckets[domain] = (tokens - 1.0, now)
|
|
103
|
+
return
|
|
104
|
+
await asyncio.sleep(0.1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Domain-specific header profiles ───────────────────────────
|
|
108
|
+
|
|
109
|
+
_DOMAIN_HEADERS: dict[str, dict[str, str]] = {
|
|
110
|
+
"google.com": {
|
|
111
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
112
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
113
|
+
},
|
|
114
|
+
"opencorporates.com": {
|
|
115
|
+
"Accept": "application/json, text/html, */*",
|
|
116
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
117
|
+
},
|
|
118
|
+
"wikipedia.org": {
|
|
119
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
120
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
121
|
+
},
|
|
122
|
+
"linkedin.com": {
|
|
123
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
124
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── OpSec HTTP Client ─────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
class OpSecClient(BaseHTTPClient if BaseHTTPClient else object):
|
|
132
|
+
"""Enterprise HTTP client: proxy chaining, per-domain rate limiting,
|
|
133
|
+
real browser header profiles, and rotating TLS fingerprints.
|
|
134
|
+
|
|
135
|
+
Usage:
|
|
136
|
+
client = OpSecClient()
|
|
137
|
+
resp = await client.get("https://crt.sh/?q=%.example.com")
|
|
138
|
+
data = resp.json()
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
proxy: str | None = None,
|
|
144
|
+
proxy_chain: list[str] | None = None,
|
|
145
|
+
rate_limit: float = 1.0,
|
|
146
|
+
max_retries: int = 3,
|
|
147
|
+
timeout: float = 20.0,
|
|
148
|
+
rotate_fingerprints: bool = True,
|
|
149
|
+
):
|
|
150
|
+
super().__init__(rate_limit=rate_limit, max_retries=max_retries, timeout=timeout)
|
|
151
|
+
self._proxy = proxy or _parse_proxy_url(_DEFAULT_PROXY)
|
|
152
|
+
self._proxy_chain = proxy_chain or [
|
|
153
|
+
p for u in _PROXY_CHAIN.split(",") if (p := _parse_proxy_url(u))
|
|
154
|
+
] if _PROXY_CHAIN else []
|
|
155
|
+
self._domain_limiter = DomainRateLimiter()
|
|
156
|
+
self._rotate_fingerprints = rotate_fingerprints
|
|
157
|
+
self._session_counter = 0
|
|
158
|
+
|
|
159
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
160
|
+
"""Create a new client with proxy and domain-appropriate headers."""
|
|
161
|
+
# Build proxy mount
|
|
162
|
+
proxy_mounts = {}
|
|
163
|
+
if self._proxy:
|
|
164
|
+
proxy_mounts["http://"] = self._proxy
|
|
165
|
+
proxy_mounts["https://"] = self._proxy
|
|
166
|
+
logger.debug("opsec: using proxy %s", self._proxy)
|
|
167
|
+
|
|
168
|
+
# Build headers with domain-appropriate profile
|
|
169
|
+
headers = {
|
|
170
|
+
"User-Agent": random.choice(USER_AGENTS),
|
|
171
|
+
"Accept": "application/json, text/plain, */*",
|
|
172
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
client = httpx.AsyncClient(
|
|
176
|
+
timeout=httpx.Timeout(self.timeout, connect=10.0),
|
|
177
|
+
follow_redirects=True,
|
|
178
|
+
headers=headers,
|
|
179
|
+
mounts=proxy_mounts if proxy_mounts else None,
|
|
180
|
+
)
|
|
181
|
+
return client
|
|
182
|
+
|
|
183
|
+
def _domain_for(self, url: str) -> str:
|
|
184
|
+
parsed = urlparse(url)
|
|
185
|
+
return parsed.hostname or ""
|
|
186
|
+
|
|
187
|
+
def _per_domain_headers(self, domain: str) -> dict[str, str]:
|
|
188
|
+
"""Get domain-specific header overrides."""
|
|
189
|
+
for pattern, hdrs in _DOMAIN_HEADERS.items():
|
|
190
|
+
if pattern in domain:
|
|
191
|
+
return hdrs
|
|
192
|
+
return {}
|
|
193
|
+
|
|
194
|
+
async def get(self, url: str, **kwargs) -> httpx.Response:
|
|
195
|
+
"""GET with per-domain rate limiting, proxy, and retry."""
|
|
196
|
+
domain = self._domain_for(url)
|
|
197
|
+
await self._domain_limiter.acquire(domain)
|
|
198
|
+
|
|
199
|
+
# Inject domain-specific headers
|
|
200
|
+
domain_hdrs = self._per_domain_headers(domain)
|
|
201
|
+
if domain_hdrs:
|
|
202
|
+
existing = kwargs.get("headers", {})
|
|
203
|
+
merged = {**domain_hdrs, **existing}
|
|
204
|
+
kwargs["headers"] = merged
|
|
205
|
+
|
|
206
|
+
# Rotate UA per request for fingerprint diversity
|
|
207
|
+
client = await self._get_client()
|
|
208
|
+
if self._rotate_fingerprints:
|
|
209
|
+
client.headers["User-Agent"] = random.choice(USER_AGENTS)
|
|
210
|
+
|
|
211
|
+
last_err = ""
|
|
212
|
+
for attempt in range(self.max_retries + 1):
|
|
213
|
+
try:
|
|
214
|
+
response = await client.get(url, **kwargs)
|
|
215
|
+
response.raise_for_status()
|
|
216
|
+
return response
|
|
217
|
+
except httpx.HTTPStatusError as e:
|
|
218
|
+
last_err = f"HTTP {e.response.status_code} from {url}"
|
|
219
|
+
if e.response.status_code == 429:
|
|
220
|
+
retry_after = float(e.response.headers.get("Retry-After", 10))
|
|
221
|
+
logger.debug("opsec: 429 from %s, waiting %ss", domain, retry_after)
|
|
222
|
+
await asyncio.sleep(retry_after)
|
|
223
|
+
continue
|
|
224
|
+
if e.response.status_code in (403, 451):
|
|
225
|
+
# Legal/censorship block — rotate proxy if available
|
|
226
|
+
if self._proxy_chain:
|
|
227
|
+
self._proxy = random.choice(self._proxy_chain)
|
|
228
|
+
client = await self._get_client()
|
|
229
|
+
logger.info("opsec: rotated proxy for %s after %d", domain, e.response.status_code)
|
|
230
|
+
continue
|
|
231
|
+
if attempt == self.max_retries:
|
|
232
|
+
self._last_error = last_err
|
|
233
|
+
raise
|
|
234
|
+
await asyncio.sleep(2 ** attempt)
|
|
235
|
+
except (httpx.RequestError, httpx.TimeoutException) as e:
|
|
236
|
+
last_err = f"Connection error for {url}: {e}"
|
|
237
|
+
if attempt == self.max_retries:
|
|
238
|
+
self._last_error = last_err
|
|
239
|
+
raise
|
|
240
|
+
# Rotate proxy on connection failure
|
|
241
|
+
if self._proxy_chain:
|
|
242
|
+
self._proxy = random.choice(self._proxy_chain)
|
|
243
|
+
client = await self._get_client()
|
|
244
|
+
logger.info("opsec: rotated proxy after connection failure to %s", domain)
|
|
245
|
+
await asyncio.sleep(2 ** attempt)
|
|
246
|
+
|
|
247
|
+
self._last_error = last_err
|
|
248
|
+
raise RuntimeError(f"Failed to fetch {url} after {self.max_retries + 1} attempts")
|
|
249
|
+
|
|
250
|
+
async def post(self, url: str, **kwargs) -> httpx.Response:
|
|
251
|
+
"""POST with per-domain rate limiting and proxy."""
|
|
252
|
+
domain = self._domain_for(url)
|
|
253
|
+
await self._domain_limiter.acquire(domain)
|
|
254
|
+
client = await self._get_client()
|
|
255
|
+
|
|
256
|
+
last_err = ""
|
|
257
|
+
for attempt in range(self.max_retries + 1):
|
|
258
|
+
try:
|
|
259
|
+
response = await client.post(url, **kwargs)
|
|
260
|
+
response.raise_for_status()
|
|
261
|
+
return response
|
|
262
|
+
except httpx.HTTPStatusError as e:
|
|
263
|
+
last_err = f"HTTP {e.response.status_code} from {url}"
|
|
264
|
+
if e.response.status_code == 429:
|
|
265
|
+
await asyncio.sleep(10)
|
|
266
|
+
continue
|
|
267
|
+
if attempt == self.max_retries:
|
|
268
|
+
self._last_error = last_err
|
|
269
|
+
raise
|
|
270
|
+
await asyncio.sleep(2 ** attempt)
|
|
271
|
+
except (httpx.RequestError, httpx.TimeoutException) as e:
|
|
272
|
+
last_err = f"Connection error for {url}: {e}"
|
|
273
|
+
if self._proxy_chain:
|
|
274
|
+
self._proxy = random.choice(self._proxy_chain)
|
|
275
|
+
client = await self._get_client()
|
|
276
|
+
if attempt == self.max_retries:
|
|
277
|
+
self._last_error = last_err
|
|
278
|
+
raise
|
|
279
|
+
await asyncio.sleep(2 ** attempt)
|
|
280
|
+
|
|
281
|
+
self._last_error = last_err
|
|
282
|
+
raise RuntimeError(f"Failed to POST {url} after {self.max_retries + 1} attempts")
|
|
283
|
+
|
|
284
|
+
def stats(self) -> dict:
|
|
285
|
+
"""Return OpSec statistics for monitoring."""
|
|
286
|
+
return {
|
|
287
|
+
"proxy": self._proxy or "direct",
|
|
288
|
+
"proxy_chain_size": len(self._proxy_chain),
|
|
289
|
+
"rate_limiter": {
|
|
290
|
+
"default_rps": self.rate_limiter.rate,
|
|
291
|
+
"active_domains": len(self._domain_limiter._buckets),
|
|
292
|
+
},
|
|
293
|
+
"errors": self._last_error,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── Global instance (lazy) ────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
_opsec_client: Optional[OpSecClient] = None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def get_opsec_client() -> OpSecClient:
|
|
303
|
+
"""Get or create the global OpSec client instance."""
|
|
304
|
+
global _opsec_client
|
|
305
|
+
if _opsec_client is None:
|
|
306
|
+
_opsec_client = OpSecClient()
|
|
307
|
+
return _opsec_client
|