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.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. 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
+ ]