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/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
|