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/web/middleware.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enterprise middleware — auth, rate limiting, tracing, structured logging.
|
|
3
|
+
|
|
4
|
+
Loaded by watson.web.app on startup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from contextvars import ContextVar
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
24
|
+
from starlette.requests import Request
|
|
25
|
+
from starlette.responses import JSONResponse, Response
|
|
26
|
+
|
|
27
|
+
# ── Structured JSON logging ─────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
class JSONFormatter(logging.Formatter):
|
|
30
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
31
|
+
log_entry = {
|
|
32
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
33
|
+
"level": record.levelname,
|
|
34
|
+
"logger": record.name,
|
|
35
|
+
"message": record.getMessage(),
|
|
36
|
+
"correlation_id": _correlation_id.get(""),
|
|
37
|
+
"module": record.module,
|
|
38
|
+
}
|
|
39
|
+
if record.exc_info and record.exc_info[1]:
|
|
40
|
+
log_entry["exception"] = str(record.exc_info[1])
|
|
41
|
+
return json.dumps(log_entry)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def setup_logging(level: str = "INFO"):
|
|
45
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
46
|
+
handler.setFormatter(JSONFormatter())
|
|
47
|
+
root = logging.getLogger()
|
|
48
|
+
root.handlers.clear()
|
|
49
|
+
root.addHandler(handler)
|
|
50
|
+
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
51
|
+
# Silence noisy libs
|
|
52
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
53
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Correlation IDs ─────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
_correlation_id: ContextVar[str] = ContextVar("correlation_id", default="")
|
|
59
|
+
|
|
60
|
+
def get_correlation_id() -> str:
|
|
61
|
+
return _correlation_id.get() or ""
|
|
62
|
+
|
|
63
|
+
# ── API Key Auth ─────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
# Load valid keys from env: WATSON_API_KEYS=key1,key2
|
|
66
|
+
_VALID_KEYS: set[str] = set()
|
|
67
|
+
_raw = os.environ.get("WATSON_API_KEYS", "")
|
|
68
|
+
if _raw:
|
|
69
|
+
_VALID_KEYS = {k.strip() for k in _raw.split(",") if k.strip()}
|
|
70
|
+
|
|
71
|
+
# Also accept the Hermes API key if configured
|
|
72
|
+
_hermes_key = os.environ.get("HERMES_API_KEY", "")
|
|
73
|
+
if _hermes_key:
|
|
74
|
+
_VALID_KEYS.add(_hermes_key)
|
|
75
|
+
|
|
76
|
+
# Public endpoints — no auth required
|
|
77
|
+
_PUBLIC_PATHS = {"/", "/health", "/docs", "/openapi.json", "/redoc", "/static", "/api/settings"}
|
|
78
|
+
|
|
79
|
+
# Terminal endpoints — require auth AND elevated permission
|
|
80
|
+
_TERMINAL_PATHS = {"/api/agent/terminal"}
|
|
81
|
+
|
|
82
|
+
# Admin keys can access terminal
|
|
83
|
+
_ADMIN_KEYS: set[str] = set()
|
|
84
|
+
_raw_admin = os.environ.get("WATSON_ADMIN_KEYS", "")
|
|
85
|
+
if _raw_admin:
|
|
86
|
+
_ADMIN_KEYS = {k.strip() for k in _raw_admin.split(",") if k.strip()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_api_key(key: str) -> bool:
|
|
90
|
+
if not _VALID_KEYS:
|
|
91
|
+
return True # No keys configured — allow all (dev mode)
|
|
92
|
+
return key in _VALID_KEYS
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_admin_key(key: str) -> bool:
|
|
96
|
+
if not _ADMIN_KEYS:
|
|
97
|
+
return False # No admin keys — deny terminal
|
|
98
|
+
return key in _ADMIN_KEYS
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Rate Limiting (token bucket per IP) ─────────────────────────
|
|
102
|
+
|
|
103
|
+
class TokenBucket:
|
|
104
|
+
def __init__(self, rate: float = 10.0, burst: int = 20):
|
|
105
|
+
self.rate = rate # tokens per second
|
|
106
|
+
self.burst = burst # max bucket size
|
|
107
|
+
self.tokens = float(burst)
|
|
108
|
+
self.last_refill = time.monotonic()
|
|
109
|
+
|
|
110
|
+
def consume(self, n: int = 1) -> bool:
|
|
111
|
+
now = time.monotonic()
|
|
112
|
+
elapsed = now - self.last_refill
|
|
113
|
+
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
|
114
|
+
self.last_refill = now
|
|
115
|
+
if self.tokens >= n:
|
|
116
|
+
self.tokens -= n
|
|
117
|
+
return True
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_buckets: dict[str, TokenBucket] = {}
|
|
122
|
+
_bucket_lock = __import__('threading').Lock()
|
|
123
|
+
|
|
124
|
+
# Higher limits for investigation endpoints
|
|
125
|
+
_INVESTIGATE_RATE = float(os.environ.get("RATE_LIMIT_INVESTIGATE", "3")) # 3/sec
|
|
126
|
+
_CHAT_RATE = float(os.environ.get("RATE_LIMIT_CHAT", "10")) # 10/sec
|
|
127
|
+
_DEFAULT_RATE = float(os.environ.get("RATE_LIMIT_DEFAULT", "30")) # 30/sec
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _get_rate_for_path(path: str) -> float:
|
|
131
|
+
if "investigate" in path:
|
|
132
|
+
return _INVESTIGATE_RATE
|
|
133
|
+
if "chat" in path:
|
|
134
|
+
return _CHAT_RATE
|
|
135
|
+
return _DEFAULT_RATE
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_rate_limit(ip: str, path: str) -> tuple[bool, dict]:
|
|
139
|
+
"""Returns (allowed, headers_dict)."""
|
|
140
|
+
with _bucket_lock:
|
|
141
|
+
if ip not in _buckets:
|
|
142
|
+
_buckets[ip] = TokenBucket(rate=_DEFAULT_RATE)
|
|
143
|
+
bucket = _buckets[ip]
|
|
144
|
+
# Adjust rate if needed
|
|
145
|
+
target_rate = _get_rate_for_path(path)
|
|
146
|
+
if bucket.rate != target_rate:
|
|
147
|
+
bucket.rate = target_rate
|
|
148
|
+
|
|
149
|
+
allowed = bucket.consume()
|
|
150
|
+
remaining = int(bucket.tokens)
|
|
151
|
+
headers = {
|
|
152
|
+
"X-RateLimit-Limit": str(int(bucket.burst)),
|
|
153
|
+
"X-RateLimit-Remaining": str(remaining),
|
|
154
|
+
"X-RateLimit-Reset": str(int(time.time() + remaining / bucket.rate)),
|
|
155
|
+
}
|
|
156
|
+
return allowed, headers
|
|
157
|
+
|
|
158
|
+
# Cleanup old buckets periodically
|
|
159
|
+
_last_cleanup = time.monotonic()
|
|
160
|
+
|
|
161
|
+
def _cleanup_buckets():
|
|
162
|
+
global _last_cleanup
|
|
163
|
+
now = time.monotonic()
|
|
164
|
+
if now - _last_cleanup < 300: # Every 5 minutes
|
|
165
|
+
return
|
|
166
|
+
_last_cleanup = now
|
|
167
|
+
with _bucket_lock:
|
|
168
|
+
stale = [ip for ip, b in _buckets.items() if now - b.last_refill > 600]
|
|
169
|
+
for ip in stale:
|
|
170
|
+
del _buckets[ip]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── Combined Middleware ──────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
class EnterpriseMiddleware(BaseHTTPMiddleware):
|
|
176
|
+
"""Auth + rate limiting + tracing + logging in one pass."""
|
|
177
|
+
|
|
178
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
179
|
+
corr_id = request.headers.get("X-Correlation-ID") or uuid.uuid4().hex[:12]
|
|
180
|
+
_correlation_id.set(corr_id)
|
|
181
|
+
|
|
182
|
+
start_time = time.monotonic()
|
|
183
|
+
ip = request.client.host if request.client else "unknown"
|
|
184
|
+
path = request.url.path
|
|
185
|
+
|
|
186
|
+
_cleanup_buckets()
|
|
187
|
+
|
|
188
|
+
# 1. Rate limit
|
|
189
|
+
allowed, rl_headers = check_rate_limit(ip, path)
|
|
190
|
+
if not allowed:
|
|
191
|
+
return JSONResponse(
|
|
192
|
+
{"error": "Rate limit exceeded", "retry_after": rl_headers["X-RateLimit-Reset"]},
|
|
193
|
+
status_code=429,
|
|
194
|
+
headers=rl_headers,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# 2. Auth (skip public paths)
|
|
198
|
+
if path not in _PUBLIC_PATHS and not path.startswith("/static"):
|
|
199
|
+
auth_header = request.headers.get("Authorization", "")
|
|
200
|
+
api_key = ""
|
|
201
|
+
if auth_header.startswith("Bearer "):
|
|
202
|
+
api_key = auth_header[7:]
|
|
203
|
+
elif auth_header.startswith("ApiKey "):
|
|
204
|
+
api_key = auth_header[7:]
|
|
205
|
+
|
|
206
|
+
if not validate_api_key(api_key):
|
|
207
|
+
logging.getLogger("watson").warning(
|
|
208
|
+
"auth_denied", extra={"ip": ip, "path": path}
|
|
209
|
+
)
|
|
210
|
+
return JSONResponse(
|
|
211
|
+
{"error": "Unauthorized — provide API key via Bearer token"},
|
|
212
|
+
status_code=401,
|
|
213
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Elevated: terminal requires admin key
|
|
217
|
+
if path in _TERMINAL_PATHS and not validate_admin_key(api_key):
|
|
218
|
+
return JSONResponse(
|
|
219
|
+
{"error": "Forbidden — admin key required for terminal access"},
|
|
220
|
+
status_code=403,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# 3. Process request
|
|
224
|
+
try:
|
|
225
|
+
response = await call_next(request)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logging.getLogger("watson").error(
|
|
228
|
+
"request_failed",
|
|
229
|
+
extra={"path": path, "method": request.method, "error": str(e)},
|
|
230
|
+
exc_info=True,
|
|
231
|
+
)
|
|
232
|
+
return JSONResponse(
|
|
233
|
+
{"error": "Internal server error", "correlation_id": corr_id},
|
|
234
|
+
status_code=500,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# 4. Add response headers
|
|
238
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
239
|
+
response.headers["X-Correlation-ID"] = corr_id
|
|
240
|
+
response.headers["X-Response-Time-Ms"] = str(duration_ms)
|
|
241
|
+
for k, v in rl_headers.items():
|
|
242
|
+
response.headers[k] = v
|
|
243
|
+
|
|
244
|
+
# 5. Structured access log
|
|
245
|
+
logging.getLogger("watson").info(
|
|
246
|
+
"request",
|
|
247
|
+
extra={
|
|
248
|
+
"method": request.method,
|
|
249
|
+
"path": path,
|
|
250
|
+
"status": response.status_code,
|
|
251
|
+
"duration_ms": duration_ms,
|
|
252
|
+
"ip": ip,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return response
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── Graceful shutdown ────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
import asyncio
|
|
262
|
+
|
|
263
|
+
_active_tasks: set[asyncio.Task] = set()
|
|
264
|
+
|
|
265
|
+
def register_task(task: asyncio.Task):
|
|
266
|
+
"""Track a background task for graceful shutdown."""
|
|
267
|
+
_active_tasks.add(task)
|
|
268
|
+
task.add_done_callback(_active_tasks.discard)
|
|
269
|
+
|
|
270
|
+
async def shutdown_tasks(timeout: float = 10.0):
|
|
271
|
+
"""Cancel all tracked tasks on shutdown."""
|
|
272
|
+
logger = logging.getLogger("watson")
|
|
273
|
+
logger.info("shutting_down", extra={"pending_tasks": len(_active_tasks)})
|
|
274
|
+
for task in list(_active_tasks):
|
|
275
|
+
task.cancel()
|
|
276
|
+
if _active_tasks:
|
|
277
|
+
done, pending = await asyncio.wait(_active_tasks, timeout=timeout)
|
|
278
|
+
if pending:
|
|
279
|
+
logger.warning("tasks_did_not_finish", extra={"count": len(pending)})
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ── Startup ──────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
def init_app(app):
|
|
285
|
+
"""Wire enterprise middleware into FastAPI app."""
|
|
286
|
+
setup_logging(os.environ.get("LOG_LEVEL", "INFO"))
|
|
287
|
+
app.add_middleware(EnterpriseMiddleware)
|
|
288
|
+
|
|
289
|
+
# Register shutdown handler
|
|
290
|
+
@app.on_event("shutdown")
|
|
291
|
+
async def on_shutdown():
|
|
292
|
+
await shutdown_tasks()
|
|
293
|
+
|
|
294
|
+
logger = logging.getLogger("watson")
|
|
295
|
+
logger.info("enterprise_middleware_initialized", extra={
|
|
296
|
+
"auth_enabled": bool(_VALID_KEYS),
|
|
297
|
+
"admin_keys_configured": bool(_ADMIN_KEYS),
|
|
298
|
+
"rate_limit_investigate": _INVESTIGATE_RATE,
|
|
299
|
+
"rate_limit_chat": _CHAT_RATE,
|
|
300
|
+
"rate_limit_default": _DEFAULT_RATE,
|
|
301
|
+
})
|