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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Retry & circuit breaker infrastructure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import random
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable, Dict, Tuple, Type
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ─────────────────────────────────────────────────────────────────
|
|
14
|
+
# Retryable exception classification
|
|
15
|
+
# ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
# TimeoutError, ConnectionError, ConnectionRefusedError, ConnectionResetError
|
|
18
|
+
# are all subclasses of OSError, so (OSError,) covers them all.
|
|
19
|
+
_RETRYABLE_EXCS: Tuple[Type[BaseException], ...] = (
|
|
20
|
+
TimeoutError,
|
|
21
|
+
ConnectionError,
|
|
22
|
+
ConnectionRefusedError,
|
|
23
|
+
ConnectionResetError,
|
|
24
|
+
OSError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_retryable(exc: BaseException) -> bool:
|
|
29
|
+
"""Return True if *exc* is a transient/network failure worth retrying."""
|
|
30
|
+
return isinstance(exc, _RETRYABLE_EXCS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ─────────────────────────────────────────────────────────────────
|
|
34
|
+
# Circuit breaker
|
|
35
|
+
# ─────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CircuitOpenError(Exception):
|
|
39
|
+
"""Raised when an operation is attempted on an open circuit."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, name: str = "", message: str = ""):
|
|
42
|
+
self.name = name
|
|
43
|
+
super().__init__(message or f"circuit '{name}' is open")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CircuitBreaker:
|
|
47
|
+
"""Simple circuit breaker — opens after *failure_threshold* failures.
|
|
48
|
+
|
|
49
|
+
States:
|
|
50
|
+
* CLOSED — normal operation, failures counted
|
|
51
|
+
* OPEN — requests short-circuit and raise :class:`CircuitOpenError`
|
|
52
|
+
* HALF-OPEN — after *reset_timeout* elapses, one probe is allowed
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
name: str = "",
|
|
58
|
+
failure_threshold: int = 5,
|
|
59
|
+
reset_timeout: float = 30.0,
|
|
60
|
+
):
|
|
61
|
+
self.name = name
|
|
62
|
+
self.failure_threshold = failure_threshold
|
|
63
|
+
self.reset_timeout = reset_timeout
|
|
64
|
+
self._failures = 0
|
|
65
|
+
self._last_failure: float = 0.0
|
|
66
|
+
self._open_since: float = 0.0
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
# -- state -----------------------------------------------------
|
|
70
|
+
@property
|
|
71
|
+
def is_open(self) -> bool:
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._failures < self.failure_threshold:
|
|
74
|
+
return False
|
|
75
|
+
# Half-open: allow a probe after reset_timeout
|
|
76
|
+
if time.monotonic() - self._open_since > self.reset_timeout:
|
|
77
|
+
return False
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
# -- transitions -----------------------------------------------
|
|
81
|
+
def success(self) -> None:
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._failures = 0
|
|
84
|
+
self._open_since = 0.0
|
|
85
|
+
|
|
86
|
+
def failure(self) -> None:
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._failures += 1
|
|
89
|
+
self._last_failure = time.monotonic()
|
|
90
|
+
if self._failures >= self.failure_threshold:
|
|
91
|
+
self._open_since = time.monotonic()
|
|
92
|
+
|
|
93
|
+
# -- context manager ------------------------------------------
|
|
94
|
+
def __enter__(self) -> "CircuitBreaker":
|
|
95
|
+
if self.is_open:
|
|
96
|
+
raise CircuitOpenError(self.name)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
100
|
+
if exc is None:
|
|
101
|
+
self.success()
|
|
102
|
+
else:
|
|
103
|
+
self.failure()
|
|
104
|
+
return False # never suppress
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Registry of named circuit breakers (singletons)
|
|
108
|
+
_circuits: Dict[str, CircuitBreaker] = {}
|
|
109
|
+
_circuits_lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_circuit(name: str, **kwargs: Any) -> CircuitBreaker:
|
|
113
|
+
"""Get-or-create a singleton :class:`CircuitBreaker` by name."""
|
|
114
|
+
with _circuits_lock:
|
|
115
|
+
cb = _circuits.get(name)
|
|
116
|
+
if cb is None:
|
|
117
|
+
cb = CircuitBreaker(name=name, **kwargs)
|
|
118
|
+
_circuits[name] = cb
|
|
119
|
+
return cb
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ─────────────────────────────────────────────────────────────────
|
|
123
|
+
# Retry decorator
|
|
124
|
+
# ─────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def retry(
|
|
128
|
+
max_attempts: int = 3,
|
|
129
|
+
base_delay: float = 1.0,
|
|
130
|
+
jitter: bool = True,
|
|
131
|
+
exceptions: Tuple[Type[BaseException], ...] = (Exception,),
|
|
132
|
+
) -> Callable[[Callable], Callable]:
|
|
133
|
+
"""Retry a callable with exponential backoff.
|
|
134
|
+
|
|
135
|
+
Only retryable exceptions (per :func:`_is_retryable`) are retried; any
|
|
136
|
+
other exception is re-raised immediately without consuming an attempt.
|
|
137
|
+
|
|
138
|
+
Works for both synchronous and ``async`` functions.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def decorator(func: Callable) -> Callable:
|
|
142
|
+
if asyncio.iscoroutinefunction(func):
|
|
143
|
+
|
|
144
|
+
@functools.wraps(func)
|
|
145
|
+
async def async_wrapper(*args, **kwargs):
|
|
146
|
+
last_exc: BaseException | None = None
|
|
147
|
+
for attempt in range(max_attempts):
|
|
148
|
+
try:
|
|
149
|
+
return await func(*args, **kwargs)
|
|
150
|
+
except BaseException as e: # noqa: BLE001
|
|
151
|
+
last_exc = e
|
|
152
|
+
if not _is_retryable(e):
|
|
153
|
+
raise
|
|
154
|
+
if attempt >= max_attempts - 1:
|
|
155
|
+
raise
|
|
156
|
+
delay = base_delay * (2 ** attempt)
|
|
157
|
+
if jitter:
|
|
158
|
+
delay = delay * random.uniform(0.5, 1.5)
|
|
159
|
+
await asyncio.sleep(delay)
|
|
160
|
+
if last_exc is not None: # pragma: no cover
|
|
161
|
+
raise last_exc
|
|
162
|
+
|
|
163
|
+
return async_wrapper
|
|
164
|
+
|
|
165
|
+
@functools.wraps(func)
|
|
166
|
+
def sync_wrapper(*args, **kwargs):
|
|
167
|
+
last_exc: BaseException | None = None
|
|
168
|
+
for attempt in range(max_attempts):
|
|
169
|
+
try:
|
|
170
|
+
return func(*args, **kwargs)
|
|
171
|
+
except BaseException as e: # noqa: BLE001
|
|
172
|
+
last_exc = e
|
|
173
|
+
if not _is_retryable(e):
|
|
174
|
+
raise
|
|
175
|
+
if attempt >= max_attempts - 1:
|
|
176
|
+
raise
|
|
177
|
+
delay = base_delay * (2 ** attempt)
|
|
178
|
+
if jitter:
|
|
179
|
+
delay = delay * random.uniform(0.5, 1.5)
|
|
180
|
+
time.sleep(delay)
|
|
181
|
+
if last_exc is not None: # pragma: no cover
|
|
182
|
+
raise last_exc
|
|
183
|
+
|
|
184
|
+
return sync_wrapper
|
|
185
|
+
|
|
186
|
+
return decorator
|
src/watson/metrics.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Metrics — Prometheus-compatible metrics endpoint and decorators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, Dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Simple counter/gauge for Prometheus text format ─────────────────
|
|
12
|
+
|
|
13
|
+
class _Counter:
|
|
14
|
+
"""Thread-safe counter."""
|
|
15
|
+
def __init__(self, name: str, help_: str = ""):
|
|
16
|
+
self.name = name
|
|
17
|
+
self.help = help_
|
|
18
|
+
self._value = 0
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
def inc(self, n: int = 1):
|
|
22
|
+
with self._lock:
|
|
23
|
+
self._value += n
|
|
24
|
+
|
|
25
|
+
def value(self) -> int:
|
|
26
|
+
with self._lock:
|
|
27
|
+
return self._value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Gauge:
|
|
31
|
+
"""Thread-safe gauge."""
|
|
32
|
+
def __init__(self, name: str, help_: str = ""):
|
|
33
|
+
self.name = name
|
|
34
|
+
self.help = help_
|
|
35
|
+
self._value = 0.0
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
def set(self, v: float):
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._value = v
|
|
41
|
+
|
|
42
|
+
def value(self) -> float:
|
|
43
|
+
with self._lock:
|
|
44
|
+
return self._value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Histogram:
|
|
48
|
+
"""Simple histogram."""
|
|
49
|
+
def __init__(self, name: str, help_: str = "", buckets=None):
|
|
50
|
+
self.name = name
|
|
51
|
+
self.help = help_
|
|
52
|
+
self.buckets = buckets or [0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0]
|
|
53
|
+
self._values: list[float] = []
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
def observe(self, v: float):
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._values.append(v)
|
|
59
|
+
|
|
60
|
+
def values(self) -> list[float]:
|
|
61
|
+
with self._lock:
|
|
62
|
+
return list(self._values)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Core metrics ────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
investigations_total = _Counter("watson_investigations_total", "Total investigations started")
|
|
68
|
+
findings_total = _Counter("watson_findings_total", "Total findings collected")
|
|
69
|
+
findings_confirmed = _Counter("watson_findings_confirmed", "Total confirmed findings")
|
|
70
|
+
investigation_duration = _Histogram("watson_investigation_duration_seconds",
|
|
71
|
+
"Investigation duration in seconds")
|
|
72
|
+
api_requests_total = _Counter("watson_api_requests_total", "Total API requests")
|
|
73
|
+
api_errors_total = _Counter("watson_api_errors_total", "Total API errors")
|
|
74
|
+
|
|
75
|
+
_registered_metrics = [investigations_total, findings_total, findings_confirmed,
|
|
76
|
+
investigation_duration, api_requests_total, api_errors_total]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Prometheus text output ──────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def prometheus_endpoint() -> str:
|
|
82
|
+
"""Generate Prometheus-compatible metrics text."""
|
|
83
|
+
lines: list[str] = []
|
|
84
|
+
|
|
85
|
+
for metric in _registered_metrics:
|
|
86
|
+
if isinstance(metric, (_Counter, _Gauge)):
|
|
87
|
+
lines.append(f"# HELP {metric.name} {metric.help}")
|
|
88
|
+
lines.append(f"# TYPE {metric.name} {'counter' if isinstance(metric, _Counter) else 'gauge'}")
|
|
89
|
+
lines.append(f"{metric.name} {metric.value()}")
|
|
90
|
+
|
|
91
|
+
# Add some system-level info
|
|
92
|
+
import os
|
|
93
|
+
import sys
|
|
94
|
+
lines.append(f"# HELP watson_info Watson version info")
|
|
95
|
+
lines.append(f"# TYPE watson_info gauge")
|
|
96
|
+
lines.append(f'watson_info{{version="0.3.0",python="{sys.version.split()[0]}"}} 1')
|
|
97
|
+
|
|
98
|
+
return "\n".join(lines) + "\n"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Track investigation decorator ───────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def track_investigation(func: Callable) -> Callable:
|
|
104
|
+
"""Decorator to count investigations and track duration."""
|
|
105
|
+
@functools.wraps(func)
|
|
106
|
+
async def async_wrapper(*args, **kwargs):
|
|
107
|
+
investigations_total.inc()
|
|
108
|
+
start = time.monotonic()
|
|
109
|
+
try:
|
|
110
|
+
result = await func(*args, **kwargs)
|
|
111
|
+
return result
|
|
112
|
+
finally:
|
|
113
|
+
investigation_duration.observe(time.monotonic() - start)
|
|
114
|
+
|
|
115
|
+
@functools.wraps(func)
|
|
116
|
+
def sync_wrapper(*args, **kwargs):
|
|
117
|
+
investigations_total.inc()
|
|
118
|
+
start = time.monotonic()
|
|
119
|
+
try:
|
|
120
|
+
result = func(*args, **kwargs)
|
|
121
|
+
return result
|
|
122
|
+
finally:
|
|
123
|
+
investigation_duration.observe(time.monotonic() - start)
|
|
124
|
+
|
|
125
|
+
import asyncio
|
|
126
|
+
if asyncio.iscoroutinefunction(func):
|
|
127
|
+
return async_wrapper
|
|
128
|
+
return sync_wrapper
|
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
from watson.utils.http import BaseHTTPClient, USER_AGENTS
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("watson.opsec")
|
|
27
|
+
|
|
28
|
+
# ── Proxy configuration ──────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
_DEFAULT_PROXY = os.environ.get("WATSON_PROXY", "") # socks5://user:pass@host:port
|
|
31
|
+
_PROXY_CHAIN = os.environ.get("WATSON_PROXY_CHAIN", "") # comma-separated SOCKS5 URLs
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_proxy_url(url: str) -> str | None:
|
|
35
|
+
"""Parse a proxy URL into httpx format. Returns None if empty/invalid."""
|
|
36
|
+
if not url or not url.strip():
|
|
37
|
+
return None
|
|
38
|
+
parsed = urlparse(url.strip())
|
|
39
|
+
if parsed.scheme in ("socks5", "socks5h"):
|
|
40
|
+
# httpx: socks5://user:pass@host:port
|
|
41
|
+
netloc = parsed.hostname or ""
|
|
42
|
+
if parsed.port:
|
|
43
|
+
netloc += f":{parsed.port}"
|
|
44
|
+
auth = f"{parsed.username}:{parsed.password}@" if parsed.username else ""
|
|
45
|
+
return f"socks5://{auth}{netloc}"
|
|
46
|
+
if parsed.scheme in ("http", "https"):
|
|
47
|
+
return url.strip()
|
|
48
|
+
logger.warning("opsec: unknown proxy scheme %s", parsed.scheme)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Per-domain rate limiter ───────────────────────────────────
|
|
53
|
+
|
|
54
|
+
class DomainRateLimiter:
|
|
55
|
+
"""Token bucket rate limiter per domain."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, default_rps: float = 1.0):
|
|
58
|
+
self.default_rps = default_rps
|
|
59
|
+
self._buckets: dict[str, tuple[float, float]] = {} # domain → (tokens, last_refill)
|
|
60
|
+
# Harsher limits for known rate-limit-happy domains
|
|
61
|
+
self._overrides: dict[str, float] = {
|
|
62
|
+
"crt.sh": 0.5,
|
|
63
|
+
"opencorporates.com": 0.5,
|
|
64
|
+
"google.com": 0.3,
|
|
65
|
+
"linkedin.com": 0.2,
|
|
66
|
+
"api.github.com": 2.0,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def _rps_for(self, domain: str) -> float:
|
|
70
|
+
for pattern, rps in self._overrides.items():
|
|
71
|
+
if pattern in domain:
|
|
72
|
+
return rps
|
|
73
|
+
return self.default_rps
|
|
74
|
+
|
|
75
|
+
async def acquire(self, domain: str) -> None:
|
|
76
|
+
rate = self._rps_for(domain)
|
|
77
|
+
while True:
|
|
78
|
+
now = time.monotonic()
|
|
79
|
+
tokens, last = self._buckets.get(domain, (rate, now))
|
|
80
|
+
elapsed = now - last
|
|
81
|
+
tokens = min(rate, tokens + elapsed * rate)
|
|
82
|
+
self._buckets[domain] = (tokens, now)
|
|
83
|
+
|
|
84
|
+
if tokens >= 1.0:
|
|
85
|
+
self._buckets[domain] = (tokens - 1.0, now)
|
|
86
|
+
return
|
|
87
|
+
await asyncio.sleep(0.1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Domain-specific header profiles ───────────────────────────
|
|
91
|
+
|
|
92
|
+
_DOMAIN_HEADERS: dict[str, dict[str, str]] = {
|
|
93
|
+
"google.com": {
|
|
94
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
95
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
96
|
+
},
|
|
97
|
+
"opencorporates.com": {
|
|
98
|
+
"Accept": "application/json, text/html, */*",
|
|
99
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
100
|
+
},
|
|
101
|
+
"wikipedia.org": {
|
|
102
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
103
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
104
|
+
},
|
|
105
|
+
"linkedin.com": {
|
|
106
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
107
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── OpSec HTTP Client ─────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
class OpSecClient(BaseHTTPClient):
|
|
115
|
+
"""Enterprise HTTP client: proxy chaining, per-domain rate limiting,
|
|
116
|
+
real browser header profiles, and rotating TLS fingerprints.
|
|
117
|
+
|
|
118
|
+
Usage:
|
|
119
|
+
client = OpSecClient()
|
|
120
|
+
resp = await client.get("https://crt.sh/?q=%.example.com")
|
|
121
|
+
data = resp.json()
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
proxy: str | None = None,
|
|
127
|
+
proxy_chain: list[str] | None = None,
|
|
128
|
+
rate_limit: float = 1.0,
|
|
129
|
+
max_retries: int = 3,
|
|
130
|
+
timeout: float = 20.0,
|
|
131
|
+
rotate_fingerprints: bool = True,
|
|
132
|
+
):
|
|
133
|
+
super().__init__(rate_limit=rate_limit, max_retries=max_retries, timeout=timeout)
|
|
134
|
+
self._proxy = proxy or _parse_proxy_url(_DEFAULT_PROXY)
|
|
135
|
+
self._proxy_chain = proxy_chain or [
|
|
136
|
+
p for u in _PROXY_CHAIN.split(",") if (p := _parse_proxy_url(u))
|
|
137
|
+
] if _PROXY_CHAIN else []
|
|
138
|
+
self._domain_limiter = DomainRateLimiter()
|
|
139
|
+
self._rotate_fingerprints = rotate_fingerprints
|
|
140
|
+
self._session_counter = 0
|
|
141
|
+
|
|
142
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
143
|
+
"""Create a new client with proxy and domain-appropriate headers."""
|
|
144
|
+
# Build proxy mount
|
|
145
|
+
proxy_mounts = {}
|
|
146
|
+
if self._proxy:
|
|
147
|
+
proxy_mounts["http://"] = self._proxy
|
|
148
|
+
proxy_mounts["https://"] = self._proxy
|
|
149
|
+
logger.debug("opsec: using proxy %s", self._proxy)
|
|
150
|
+
|
|
151
|
+
# Build headers with domain-appropriate profile
|
|
152
|
+
headers = {
|
|
153
|
+
"User-Agent": random.choice(USER_AGENTS),
|
|
154
|
+
"Accept": "application/json, text/plain, */*",
|
|
155
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
client = httpx.AsyncClient(
|
|
159
|
+
timeout=httpx.Timeout(self.timeout, connect=10.0),
|
|
160
|
+
follow_redirects=True,
|
|
161
|
+
headers=headers,
|
|
162
|
+
mounts=proxy_mounts if proxy_mounts else None,
|
|
163
|
+
)
|
|
164
|
+
return client
|
|
165
|
+
|
|
166
|
+
def _domain_for(self, url: str) -> str:
|
|
167
|
+
parsed = urlparse(url)
|
|
168
|
+
return parsed.hostname or ""
|
|
169
|
+
|
|
170
|
+
def _per_domain_headers(self, domain: str) -> dict[str, str]:
|
|
171
|
+
"""Get domain-specific header overrides."""
|
|
172
|
+
for pattern, hdrs in _DOMAIN_HEADERS.items():
|
|
173
|
+
if pattern in domain:
|
|
174
|
+
return hdrs
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
async def get(self, url: str, **kwargs) -> httpx.Response:
|
|
178
|
+
"""GET with per-domain rate limiting, proxy, and retry."""
|
|
179
|
+
domain = self._domain_for(url)
|
|
180
|
+
await self._domain_limiter.acquire(domain)
|
|
181
|
+
|
|
182
|
+
# Inject domain-specific headers
|
|
183
|
+
domain_hdrs = self._per_domain_headers(domain)
|
|
184
|
+
if domain_hdrs:
|
|
185
|
+
existing = kwargs.get("headers", {})
|
|
186
|
+
merged = {**domain_hdrs, **existing}
|
|
187
|
+
kwargs["headers"] = merged
|
|
188
|
+
|
|
189
|
+
# Rotate UA per request for fingerprint diversity
|
|
190
|
+
client = await self._get_client()
|
|
191
|
+
if self._rotate_fingerprints:
|
|
192
|
+
client.headers["User-Agent"] = random.choice(USER_AGENTS)
|
|
193
|
+
|
|
194
|
+
last_err = ""
|
|
195
|
+
for attempt in range(self.max_retries + 1):
|
|
196
|
+
try:
|
|
197
|
+
response = await client.get(url, **kwargs)
|
|
198
|
+
response.raise_for_status()
|
|
199
|
+
return response
|
|
200
|
+
except httpx.HTTPStatusError as e:
|
|
201
|
+
last_err = f"HTTP {e.response.status_code} from {url}"
|
|
202
|
+
if e.response.status_code == 429:
|
|
203
|
+
retry_after = float(e.response.headers.get("Retry-After", 10))
|
|
204
|
+
logger.debug("opsec: 429 from %s, waiting %ss", domain, retry_after)
|
|
205
|
+
await asyncio.sleep(retry_after)
|
|
206
|
+
continue
|
|
207
|
+
if e.response.status_code in (403, 451):
|
|
208
|
+
# Legal/censorship block — rotate proxy if available
|
|
209
|
+
if self._proxy_chain:
|
|
210
|
+
self._proxy = random.choice(self._proxy_chain)
|
|
211
|
+
client = await self._get_client()
|
|
212
|
+
logger.info("opsec: rotated proxy for %s after %d", domain, e.response.status_code)
|
|
213
|
+
continue
|
|
214
|
+
if attempt == self.max_retries:
|
|
215
|
+
self._last_error = last_err
|
|
216
|
+
raise
|
|
217
|
+
await asyncio.sleep(2 ** attempt)
|
|
218
|
+
except (httpx.RequestError, httpx.TimeoutException) as e:
|
|
219
|
+
last_err = f"Connection error for {url}: {e}"
|
|
220
|
+
if attempt == self.max_retries:
|
|
221
|
+
self._last_error = last_err
|
|
222
|
+
raise
|
|
223
|
+
# Rotate proxy on connection failure
|
|
224
|
+
if self._proxy_chain:
|
|
225
|
+
self._proxy = random.choice(self._proxy_chain)
|
|
226
|
+
client = await self._get_client()
|
|
227
|
+
logger.info("opsec: rotated proxy after connection failure to %s", domain)
|
|
228
|
+
await asyncio.sleep(2 ** attempt)
|
|
229
|
+
|
|
230
|
+
self._last_error = last_err
|
|
231
|
+
raise RuntimeError(f"Failed to fetch {url} after {self.max_retries + 1} attempts")
|
|
232
|
+
|
|
233
|
+
async def post(self, url: str, **kwargs) -> httpx.Response:
|
|
234
|
+
"""POST with per-domain rate limiting and proxy."""
|
|
235
|
+
domain = self._domain_for(url)
|
|
236
|
+
await self._domain_limiter.acquire(domain)
|
|
237
|
+
client = await self._get_client()
|
|
238
|
+
|
|
239
|
+
last_err = ""
|
|
240
|
+
for attempt in range(self.max_retries + 1):
|
|
241
|
+
try:
|
|
242
|
+
response = await client.post(url, **kwargs)
|
|
243
|
+
response.raise_for_status()
|
|
244
|
+
return response
|
|
245
|
+
except httpx.HTTPStatusError as e:
|
|
246
|
+
last_err = f"HTTP {e.response.status_code} from {url}"
|
|
247
|
+
if e.response.status_code == 429:
|
|
248
|
+
await asyncio.sleep(10)
|
|
249
|
+
continue
|
|
250
|
+
if attempt == self.max_retries:
|
|
251
|
+
self._last_error = last_err
|
|
252
|
+
raise
|
|
253
|
+
await asyncio.sleep(2 ** attempt)
|
|
254
|
+
except (httpx.RequestError, httpx.TimeoutException) as e:
|
|
255
|
+
last_err = f"Connection error for {url}: {e}"
|
|
256
|
+
if self._proxy_chain:
|
|
257
|
+
self._proxy = random.choice(self._proxy_chain)
|
|
258
|
+
client = await self._get_client()
|
|
259
|
+
if attempt == self.max_retries:
|
|
260
|
+
self._last_error = last_err
|
|
261
|
+
raise
|
|
262
|
+
await asyncio.sleep(2 ** attempt)
|
|
263
|
+
|
|
264
|
+
self._last_error = last_err
|
|
265
|
+
raise RuntimeError(f"Failed to POST {url} after {self.max_retries + 1} attempts")
|
|
266
|
+
|
|
267
|
+
def stats(self) -> dict:
|
|
268
|
+
"""Return OpSec statistics for monitoring."""
|
|
269
|
+
return {
|
|
270
|
+
"proxy": self._proxy or "direct",
|
|
271
|
+
"proxy_chain_size": len(self._proxy_chain),
|
|
272
|
+
"rate_limiter": {
|
|
273
|
+
"default_rps": self.rate_limiter.rate,
|
|
274
|
+
"active_domains": len(self._domain_limiter._buckets),
|
|
275
|
+
},
|
|
276
|
+
"errors": self._last_error,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── Global instance (lazy) ────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
_opsec_client: Optional[OpSecClient] = None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_opsec_client() -> OpSecClient:
|
|
286
|
+
"""Get or create the global OpSec client instance."""
|
|
287
|
+
global _opsec_client
|
|
288
|
+
if _opsec_client is None:
|
|
289
|
+
_opsec_client = OpSecClient()
|
|
290
|
+
return _opsec_client
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Watson Orchestration — the intelligence pipeline that makes Watson more than a search engine.
|
|
2
|
+
|
|
3
|
+
Layers on top of the 7-phase sequential engine with:
|
|
4
|
+
classify → surface → pivot → deep → dark → analyze → report
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from .engine import OrchestrationEngine, get_engine
|
|
9
|
+
from .resolution import (
|
|
10
|
+
build_intelligence_picture,
|
|
11
|
+
resolve_entities,
|
|
12
|
+
propagate_confidence,
|
|
13
|
+
cross_reference_advanced,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"OrchestrationEngine",
|
|
18
|
+
"get_engine",
|
|
19
|
+
"build_intelligence_picture",
|
|
20
|
+
"resolve_entities",
|
|
21
|
+
"propagate_confidence",
|
|
22
|
+
"cross_reference_advanced",
|
|
23
|
+
]
|