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
watson/toolkit_api.py ADDED
@@ -0,0 +1,347 @@
1
+ """Bellingcat direct API integration — real data from real sources.
2
+
3
+ Stripped-down version that hits the actual APIs without the full
4
+ OSINTTool/registry framework. Imported directly by the engine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import re
13
+ import ssl as _ssl
14
+ from urllib.parse import quote
15
+ from typing import Optional
16
+
17
+ try:
18
+ import certifi as _certifi
19
+ _SSL_CTX = _ssl.create_default_context(cafile=_certifi.where())
20
+ except ImportError:
21
+ _SSL_CTX = False
22
+
23
+ import aiohttp
24
+
25
+
26
+ # ── API key loading ───────────────────────────────────────────
27
+
28
+ def _load_api_keys() -> dict[str, str]:
29
+ """Load API keys from the unified key store (JSON + env fallback)."""
30
+ try:
31
+ from watson.api_keys import get_key
32
+ return {
33
+ "opencorporates": get_key("opencorporates"),
34
+ "opensanctions": get_key("opensanctions"),
35
+ }
36
+ except ImportError:
37
+ keys = {}
38
+ for env_var, slug in [
39
+ ("OPENCORPORATES_API_KEY", "opencorporates"),
40
+ ("OPENSANCTIONS_API_KEY", "opensanctions"),
41
+ ]:
42
+ val = os.environ.get(env_var, "")
43
+ if val:
44
+ keys[slug] = val
45
+ return keys
46
+
47
+
48
+ # ── API definitions ────────────────────────────────────────────
49
+
50
+ DIRECT_APIS: dict[str, dict] = {
51
+ "crt.sh": {
52
+ "search_url": "https://crt.sh/?q=%25.{query}&output=json",
53
+ "extract": "root",
54
+ "auth": False,
55
+ },
56
+ "Wayback CDX": {
57
+ "search_url": "https://web.archive.org/cdx/search/cdx?url=*.{query}/*&output=json&fl=timestamp,original,statuscode&limit=50",
58
+ "extract": "root",
59
+ "auth": False,
60
+ },
61
+ "OpenCorporates": {
62
+ "search_url": "https://api.opencorporates.com/v0.4/companies/search?q={query}",
63
+ "extract": "companies",
64
+ "auth": True,
65
+ "auth_env": "OPENCORPORATES_API_KEY",
66
+ "get_key_url": "https://opencorporates.com/api_accounts/new",
67
+ },
68
+ "OpenSanctions": {
69
+ "search_url": "https://api.opensanctions.org/search/default?q={query}",
70
+ "extract": "results",
71
+ "auth": True,
72
+ "auth_env": "OPENSANCTIONS_API_KEY",
73
+ "get_key_url": "https://www.opensanctions.org/api/",
74
+ },
75
+ "Wikidata": {
76
+ "search_url": "https://www.wikidata.org/w/api.php?action=wbsearchentities&search={query}&language=en&format=json&limit=10&origin=*",
77
+ "extract": "search",
78
+ "auth": False,
79
+ },
80
+ "Wikipedia": {
81
+ "search_url": "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={query}&format=json&srlimit=10&origin=*",
82
+ "extract": "query.search",
83
+ "auth": False,
84
+ },
85
+ "urlscan.io": {
86
+ "search_url": "https://urlscan.io/api/v1/search/?q={query}",
87
+ "extract": "results",
88
+ "auth": False,
89
+ # urlscan expects domain:prefix for domain searches, plain text otherwise
90
+ "query_formatter": lambda q: f"domain:{q}" if "." in q and not q.startswith("domain:") else q,
91
+ },
92
+ }
93
+
94
+ # ── API selection per target type ──────────────────────────────
95
+
96
+ API_SELECTION: dict[str, list[str]] = {
97
+ "domain": ["crt.sh", "urlscan.io", "Wayback CDX", "Wikidata"],
98
+ "company": ["OpenCorporates", "Wikidata", "Wikipedia"], # OpenSanctions via corporate agent (retry wrapper)
99
+ "person": ["Wikidata", "Wikipedia"], # OpenSanctions via corporate agent (retry wrapper)
100
+ "email": ["Wikidata"],
101
+ "topic": ["Wikipedia", "Wikidata"],
102
+ }
103
+
104
+
105
+ class BellingcatAPI:
106
+ """Direct API integration for Bellingcat OSINT tools.
107
+
108
+ Hits crt.sh, Wayback CDX, OpenCorporates, Wikidata, Wikipedia,
109
+ OpenSanctions, and more — in parallel with rate limiting.
110
+ """
111
+
112
+ def __init__(self, max_concurrent: int = 5, timeout: int = 20):
113
+ self.max_concurrent = max_concurrent
114
+ self.timeout = timeout
115
+ self._api_keys = _load_api_keys()
116
+
117
+ async def investigate(
118
+ self, query: str, target_type: str = "topic"
119
+ ) -> list[dict]:
120
+ """Run relevant API calls for a target and return findings.
121
+
122
+ Auth failures return error findings (not None) so the user
123
+ knows exactly what's missing — never swallow API errors.
124
+ """
125
+ api_names = API_SELECTION.get(target_type, API_SELECTION["topic"])
126
+ sem = asyncio.Semaphore(self.max_concurrent)
127
+
128
+ async def _call_one(name: str) -> dict | None:
129
+ api_def = DIRECT_APIS.get(name)
130
+ if not api_def:
131
+ return {
132
+ "title": f"⚠️ {name}: API not configured",
133
+ "description": f"API '{name}' is not in the registry.",
134
+ "source_type": "error",
135
+ "confidence": 0.0,
136
+ "tool": name,
137
+ }
138
+ async with sem:
139
+ return await self._call_api(name, api_def, query)
140
+
141
+ tasks = [_call_one(name) for name in api_names]
142
+ results = await asyncio.gather(*tasks, return_exceptions=True)
143
+
144
+ findings = []
145
+ for i, r in enumerate(results):
146
+ if isinstance(r, dict) and r.get("title"):
147
+ findings.append(r)
148
+ elif isinstance(r, Exception):
149
+ api_name = api_names[i] if i < len(api_names) else "unknown"
150
+ findings.append({
151
+ "title": f"⚠️ {api_name}: Internal error",
152
+ "description": f"Unexpected error: {type(r).__name__}: {str(r)[:200]}",
153
+ "source_type": "error",
154
+ "confidence": 0.0,
155
+ "tool": api_name,
156
+ })
157
+
158
+ return findings
159
+
160
+ async def _call_api(
161
+ self, name: str, api_def: dict, query: str
162
+ ) -> dict | None:
163
+ """Call a single API and return a finding dict.
164
+
165
+ Never returns None — returns error findings so failures are
166
+ visible to the user instead of silently dropped.
167
+ """
168
+ # ── Auth check ──────────────────────────────────────────
169
+ api_key = None
170
+ if api_def.get("auth"):
171
+ auth_env = api_def["auth_env"]
172
+ # Normalize: strip "API_KEY" suffix and trailing underscore
173
+ slug = auth_env.lower().replace("_api_key", "").strip("_")
174
+ api_key = self._api_keys.get(
175
+ slug,
176
+ self._api_keys.get(auth_env.lower(), ""),
177
+ )
178
+ if not api_key:
179
+ api_key = os.environ.get(auth_env, "")
180
+ if not api_key:
181
+ return {
182
+ "title": f"⚠️ {name}: API key required",
183
+ "description": (
184
+ f"{name} requires an API key. Set {auth_env} "
185
+ f"in your environment or .env file. "
186
+ f"Get a key at: {api_def.get('get_key_url', 'N/A')}"
187
+ ),
188
+ "source_type": "error",
189
+ "confidence": 0.0,
190
+ "tool": name,
191
+ }
192
+
193
+ try:
194
+ url = api_def["search_url"]
195
+ # Apply query formatter if defined (e.g. urlscan.io needs domain: prefix)
196
+ formatted_query = query
197
+ if "query_formatter" in api_def:
198
+ formatted_query = api_def["query_formatter"](query)
199
+ url = url.replace("{query}", quote(formatted_query, safe=""))
200
+ url = url.replace("%25.{query}", "%25." + quote(formatted_query, safe=""))
201
+
202
+ headers = {
203
+ "User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)",
204
+ }
205
+
206
+ # Inject API key for auth-required APIs
207
+ if api_def.get("auth") and api_key:
208
+ if name == "OpenCorporates":
209
+ # OpenCorporates uses query param for API token
210
+ url += f"&api_token={quote(api_key, safe='')}"
211
+ elif name == "OpenSanctions":
212
+ headers["Authorization"] = f"ApiKey {api_key}"
213
+
214
+ async with aiohttp.ClientSession(
215
+ timeout=aiohttp.ClientTimeout(total=self.timeout),
216
+ headers=headers,
217
+ ) as session:
218
+ async with session.get(url, ssl=_SSL_CTX) as resp:
219
+ # ── Error responses — surface, don't swallow ──
220
+ if resp.status == 401 or resp.status == 403:
221
+ return {
222
+ "title": f"⚠️ {name}: Authentication failed",
223
+ "description": (
224
+ f"{name} returned {resp.status}. "
225
+ f"Check your API key ({api_def.get('auth_env', 'N/A')}). "
226
+ f"Get a key: {api_def.get('get_key_url', 'N/A')}"
227
+ ),
228
+ "source_type": "error",
229
+ "confidence": 0.0,
230
+ "tool": name,
231
+ }
232
+ if resp.status == 202 and "x-amzn-waf-action" in str(resp.headers):
233
+ return {
234
+ "title": f"⚠️ {name}: Bot detection triggered",
235
+ "description": (
236
+ f"{name} is behind CloudFront WAF and blocked the request. "
237
+ f"This API is not currently usable for automated queries."
238
+ ),
239
+ "source_type": "error",
240
+ "confidence": 0.0,
241
+ "tool": name,
242
+ }
243
+ if resp.status == 429:
244
+ return {
245
+ "title": f"⚠️ {name}: Rate limited",
246
+ "description": f"{name} returned 429 — too many requests. Retry later.",
247
+ "source_type": "error",
248
+ "confidence": 0.0,
249
+ "tool": name,
250
+ }
251
+ if resp.status not in (200, 202):
252
+ body = await resp.text()
253
+ return {
254
+ "title": f"⚠️ {name}: HTTP {resp.status}",
255
+ "description": f"{name} returned {resp.status}: {body[:200]}",
256
+ "source_type": "error",
257
+ "confidence": 0.0,
258
+ "tool": name,
259
+ }
260
+
261
+ if "json" in resp.content_type or name in ("crt.sh", "Wayback CDX", "urlscan.io"):
262
+ try:
263
+ data = await resp.json()
264
+ except Exception:
265
+ text = await resp.text()
266
+ return {
267
+ "title": f"⚠️ {name}: JSON parse failed",
268
+ "description": f"Expected JSON but got: {text[:200]}",
269
+ "source_type": "error",
270
+ "confidence": 0.0,
271
+ "tool": name,
272
+ }
273
+ else:
274
+ text = await resp.text()
275
+ try:
276
+ data = json.loads(text)
277
+ except json.JSONDecodeError:
278
+ data = {"raw": text[:1000]}
279
+
280
+ # Extract meaningful data
281
+ extractor = api_def.get("extract", "")
282
+ result = data
283
+ if extractor and extractor != "root":
284
+ for key in extractor.split("."):
285
+ if isinstance(result, dict):
286
+ result = result.get(key, [])
287
+ elif isinstance(result, list):
288
+ break
289
+
290
+ count = len(result) if isinstance(result, list) else (1 if result else 0)
291
+
292
+ # Build description from samples
293
+ samples = []
294
+ if isinstance(result, list) and result:
295
+ for item in result[:5]:
296
+ samples.append(self._summarize(name, item))
297
+
298
+ description = f"Found {count} result(s)."
299
+ if samples:
300
+ description += " " + "; ".join(samples[:3])
301
+
302
+ return {
303
+ "title": f"✓ {name}: {count} Results",
304
+ "description": description[:2000],
305
+ "source_type": "bellingcat",
306
+ "source_url": url.split("?")[0],
307
+ "confidence": min(0.85, 0.3 + count * 0.05) if count > 0 else 0.0,
308
+ "evidence": [url],
309
+ "tool": name,
310
+ }
311
+
312
+ except asyncio.TimeoutError:
313
+ return {
314
+ "title": f"⚠️ {name}: Timeout",
315
+ "description": f"{name} timed out after {self.timeout}s. The API may be slow or unreachable.",
316
+ "source_type": "error",
317
+ "confidence": 0.0,
318
+ "tool": name,
319
+ }
320
+ except Exception as e:
321
+ return {
322
+ "title": f"⚠️ {name}: Error",
323
+ "description": f"{type(e).__name__}: {str(e)[:200]}",
324
+ "source_type": "error",
325
+ "confidence": 0.0,
326
+ "tool": name,
327
+ }
328
+
329
+ @staticmethod
330
+ def _summarize(api_name: str, item) -> str:
331
+ """Create a one-line summary of an API result."""
332
+ if not isinstance(item, dict):
333
+ return str(item)[:120]
334
+
335
+ summarizers = {
336
+ "crt.sh": lambda i: f"{i.get('common_name', '?')} ({str(i.get('not_before', ''))[:10]})",
337
+ "Wayback CDX": lambda i: f"{i[2] if isinstance(i, list) and len(i) > 2 else '?'} @ {i[1] if isinstance(i, list) and len(i) > 1 else '?'}",
338
+ "OpenCorporates": lambda i: f"{i.get('company', {}).get('name', '?')} ({i.get('company', {}).get('jurisdiction_code', '?')})",
339
+ "Wikidata": lambda i: f"{i.get('label', '?')} ({i.get('id', '?')}) — {i.get('description', '')[:60]}",
340
+ "Wikipedia": lambda i: f"{i.get('title', '?')}",
341
+ "urlscan.io": lambda i: f"{i.get('page', {}).get('url', '?')[:80]}",
342
+ }
343
+ fn = summarizers.get(api_name, lambda i: json.dumps(i, default=str)[:120])
344
+ try:
345
+ return fn(item)
346
+ except Exception:
347
+ return json.dumps(item, default=str)[:120]
@@ -0,0 +1,95 @@
1
+ """
2
+ Bellingcat Automation — bridge module for toolkit.py.
3
+
4
+ Thin wrapper around bellingcat_api.BellingcatAPI that provides
5
+ the interface expected by the BellingcatToolkit._run_automation() method.
6
+
7
+ Previously this module didn't exist, causing "No module named
8
+ 'watson.bellingcat_automation'" errors in every investigation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from watson.toolkit_api import BellingcatAPI, API_SELECTION, DIRECT_APIS
14
+
15
+ # ── Target-type → API tool mapping ─────────────────────────────
16
+
17
+ TARGET_API_MAP: dict[str, list[str]] = {
18
+ "person": API_SELECTION.get("person", ["Wikidata", "Wikipedia", "OpenSanctions"]),
19
+ "company": API_SELECTION.get("company", ["OpenCorporates", "Wikidata", "Wikipedia", "OpenSanctions"]),
20
+ "domain": API_SELECTION.get("domain", ["crt.sh", "urlscan.io", "Wayback CDX", "Wikidata"]),
21
+ "email": API_SELECTION.get("email", ["Wikidata"]),
22
+ "topic": API_SELECTION.get("topic", ["Wikipedia", "Wikidata"]),
23
+ }
24
+
25
+ # ── Automation class ────────────────────────────────────────────
26
+
27
+ class BellingcatAutomation:
28
+ """Run automated Bellingcat API calls for a target type.
29
+
30
+ Wraps BellingcatAPI with the interface expected by toolkit.py:
31
+ - run_category(target_type, query, tool_names) → dict[str, list[dict]]
32
+ - results_to_findings(tool_name, results, query, target_type) → list[dict]
33
+ """
34
+
35
+ def __init__(self, api_keys: dict | None = None):
36
+ self._api = BellingcatAPI()
37
+ if api_keys:
38
+ self._api._api_keys.update(api_keys)
39
+
40
+ async def run_category(
41
+ self, target_type: str, query: str, tool_names: list[str]
42
+ ) -> dict[str, list[dict]]:
43
+ """Run automated tools for a target type. Returns {tool_name: [results]}."""
44
+ import asyncio
45
+
46
+ results: dict[str, list[dict]] = {}
47
+
48
+ async def _run_one(name: str):
49
+ api_def = DIRECT_APIS.get(name)
50
+ if not api_def:
51
+ return name, [{
52
+ "title": f"{name}: not configured",
53
+ "description": f"Tool '{name}' is not in DIRECT_APIS registry.",
54
+ "source_type": "error",
55
+ "confidence": 0.0,
56
+ }]
57
+ result = await self._api._call_api(name, api_def, query)
58
+ return name, [result] if result else []
59
+
60
+ # Run all tools concurrently
61
+ tasks = [_run_one(name) for name in tool_names]
62
+ gathered = await asyncio.gather(*tasks, return_exceptions=True)
63
+
64
+ for item in gathered:
65
+ if isinstance(item, BaseException):
66
+ continue
67
+ name, findings = item
68
+ if findings:
69
+ results[name] = findings
70
+
71
+ return results
72
+
73
+ def results_to_findings(
74
+ self, tool_name: str, results: list[dict], query: str, target_type: str
75
+ ) -> list[dict]:
76
+ """Convert raw API results to Watson finding dicts."""
77
+ findings = []
78
+ for r in results:
79
+ if not isinstance(r, dict):
80
+ continue
81
+ # Already in finding format from bellingcat_api
82
+ if "title" in r:
83
+ findings.append(r)
84
+ else:
85
+ # Raw result — wrap it
86
+ findings.append({
87
+ "title": f"{tool_name}: {query}",
88
+ "description": str(r)[:500],
89
+ "evidence": [],
90
+ "confidence": 0.5,
91
+ "severity": "info",
92
+ "tool": tool_name,
93
+ "source_type": "api",
94
+ })
95
+ return findings