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/terminal.py ADDED
@@ -0,0 +1,410 @@
1
+ """Terminal Manager — rich command execution with background processes.
2
+
3
+ Upgrade from single-shot subprocess to:
4
+ - Background process execution with async tracking
5
+ - Output streaming and log retrieval
6
+ - Process lifecycle (poll, wait, kill, stdin write)
7
+ - PTY mode for interactive commands
8
+ - Watch patterns for output monitoring
9
+ - Concurrent process pool with size limits
10
+
11
+ Inspired by Hermes Agent's terminal tool capabilities.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import pty
19
+ import select
20
+ import signal
21
+ import subprocess
22
+ import threading
23
+ import time
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ logger = logging.getLogger("watson.terminal")
30
+
31
+
32
+ @dataclass
33
+ class Process:
34
+ """A managed process — foreground or background."""
35
+ session_id: str
36
+ command: str
37
+ workdir: str
38
+ process: subprocess.Popen | None = None
39
+ status: str = "pending" # pending, running, completed, killed, error
40
+ exit_code: int | None = None
41
+ output: list[str] = field(default_factory=list)
42
+ error: str = ""
43
+ started_at: float = 0.0
44
+ finished_at: float | None = None
45
+ pty_fd: int | None = None # PTY file descriptor (if PTY mode)
46
+ background: bool = False
47
+ timeout: float = 300.0
48
+ max_output_lines: int = 5000
49
+ _output_lock: threading.Lock = field(default_factory=threading.Lock)
50
+ _watch_patterns: list[str] = field(default_factory=list)
51
+ _watch_callbacks: dict[str, callable] = field(default_factory=dict)
52
+
53
+ @property
54
+ def output_text(self) -> str:
55
+ with self._output_lock:
56
+ return "\n".join(self.output[-1000:]) # last 1000 lines
57
+
58
+ @property
59
+ def duration(self) -> float:
60
+ if self.started_at == 0:
61
+ return 0
62
+ end = self.finished_at or time.time()
63
+ return round(end - self.started_at, 2)
64
+
65
+ def to_dict(self) -> dict:
66
+ return {
67
+ "session_id": self.session_id,
68
+ "command": self.command[:200],
69
+ "workdir": self.workdir,
70
+ "status": self.status,
71
+ "exit_code": self.exit_code,
72
+ "output_length": len(self.output),
73
+ "error": self.error[:500] if self.error else None,
74
+ "duration": self.duration,
75
+ "background": self.background,
76
+ }
77
+
78
+
79
+ class TerminalManager:
80
+ """Manages concurrent command execution with background support.
81
+
82
+ Usage:
83
+ tm = TerminalManager(max_processes=10)
84
+
85
+ # Foreground
86
+ result = tm.run("ls -la", workdir="/tmp")
87
+ print(result.output_text)
88
+
89
+ # Background
90
+ session_id = tm.run_background("python server.py")
91
+ # ... later ...
92
+ proc = tm.poll(session_id)
93
+ if proc.status == "completed":
94
+ print(proc.output_text)
95
+ """
96
+
97
+ def __init__(self, max_processes: int = 20):
98
+ self._processes: dict[str, Process] = {}
99
+ self._max_processes = max_processes
100
+ self._lock = threading.Lock()
101
+
102
+ # ── Execution ──────────────────────────────────────────────────
103
+
104
+ def run(
105
+ self,
106
+ command: str,
107
+ workdir: str | None = None,
108
+ timeout: float = 300.0,
109
+ pty_mode: bool = False,
110
+ env: dict[str, str] | None = None,
111
+ ) -> Process:
112
+ """Execute a command and wait for completion (foreground).
113
+
114
+ Args:
115
+ command: Shell command to execute
116
+ workdir: Working directory (defaults to cwd)
117
+ timeout: Max execution time in seconds
118
+ pty_mode: Use pseudo-terminal (for interactive commands)
119
+ env: Additional environment variables
120
+
121
+ Returns:
122
+ Process object with output and exit code
123
+ """
124
+ proc = self._create_process(
125
+ command, workdir=workdir or os.getcwd(),
126
+ background=False, timeout=timeout, pty_mode=pty_mode,
127
+ )
128
+ self._run_sync(proc, env=env)
129
+ return proc
130
+
131
+ def run_background(
132
+ self,
133
+ command: str,
134
+ workdir: str | None = None,
135
+ timeout: float = 1800.0,
136
+ pty_mode: bool = False,
137
+ watch_patterns: list[str] | None = None,
138
+ env: dict[str, str] | None = None,
139
+ ) -> str:
140
+ """Execute a command in the background.
141
+
142
+ Returns:
143
+ session_id for later polling/control
144
+ """
145
+ proc = self._create_process(
146
+ command, workdir=workdir or os.getcwd(),
147
+ background=True, timeout=timeout, pty_mode=pty_mode,
148
+ )
149
+ if watch_patterns:
150
+ proc._watch_patterns = watch_patterns
151
+
152
+ thread = threading.Thread(
153
+ target=self._run_thread, args=(proc, env), daemon=True
154
+ )
155
+ thread.start()
156
+
157
+ return proc.session_id
158
+
159
+ def _create_process(
160
+ self,
161
+ command: str,
162
+ workdir: str,
163
+ background: bool,
164
+ timeout: float,
165
+ pty_mode: bool,
166
+ ) -> Process:
167
+ """Create a Process object and register it."""
168
+ session_id = f"term-{uuid.uuid4().hex[:8]}"
169
+
170
+ with self._lock:
171
+ # Clean up old completed processes if over limit
172
+ if len(self._processes) >= self._max_processes:
173
+ self._cleanup_old()
174
+
175
+ proc = Process(
176
+ session_id=session_id,
177
+ command=command,
178
+ workdir=workdir,
179
+ background=background,
180
+ timeout=timeout,
181
+ started_at=time.time(),
182
+ )
183
+ self._processes[session_id] = proc
184
+
185
+ return proc
186
+
187
+ def _run_sync(self, proc: Process, env: dict[str, str] | None = None):
188
+ """Execute a process synchronously."""
189
+ try:
190
+ proc.status = "running"
191
+
192
+ if proc.pty_fd is not None:
193
+ # PTY mode
194
+ self._run_pty(proc, env)
195
+ else:
196
+ # Standard subprocess
197
+ merged_env = os.environ.copy()
198
+ if env:
199
+ merged_env.update(env)
200
+
201
+ proc.process = subprocess.Popen(
202
+ proc.command,
203
+ shell=True,
204
+ cwd=proc.workdir,
205
+ stdout=subprocess.PIPE,
206
+ stderr=subprocess.PIPE,
207
+ text=True,
208
+ env=merged_env,
209
+ )
210
+
211
+ try:
212
+ stdout, stderr = proc.process.communicate(timeout=proc.timeout)
213
+ proc.exit_code = proc.process.returncode
214
+
215
+ if stdout:
216
+ proc.output = stdout.split("\n")[: proc.max_output_lines]
217
+ if stderr:
218
+ proc.error = stderr[:2000]
219
+
220
+ proc.status = "completed" if proc.exit_code == 0 else "error"
221
+ except subprocess.TimeoutExpired:
222
+ proc.process.kill()
223
+ proc.process.communicate()
224
+ proc.exit_code = -1
225
+ proc.status = "killed"
226
+ proc.error = f"Timed out after {proc.timeout}s"
227
+
228
+ except Exception as e:
229
+ proc.status = "error"
230
+ proc.error = str(e)[:1000]
231
+ finally:
232
+ proc.finished_at = time.time()
233
+
234
+ def _run_thread(self, proc: Process, env: dict[str, str] | None = None):
235
+ """Run process in background thread."""
236
+ self._run_sync(proc, env)
237
+
238
+ def _run_pty(self, proc: Process, env: dict[str, str] | None = None):
239
+ """Execute command in a pseudo-terminal."""
240
+ master_fd, slave_fd = pty.openpty()
241
+
242
+ merged_env = os.environ.copy()
243
+ if env:
244
+ merged_env.update(env)
245
+
246
+ proc.process = subprocess.Popen(
247
+ proc.command,
248
+ shell=True,
249
+ cwd=proc.workdir,
250
+ stdin=slave_fd,
251
+ stdout=slave_fd,
252
+ stderr=slave_fd,
253
+ close_fds=True,
254
+ env=merged_env,
255
+ preexec_fn=os.setsid,
256
+ )
257
+
258
+ os.close(slave_fd)
259
+ proc.pty_fd = master_fd
260
+
261
+ try:
262
+ deadline = time.time() + proc.timeout
263
+ buffer = b""
264
+
265
+ while time.time() < deadline:
266
+ r, _, _ = select.select([master_fd], [], [], 1.0)
267
+ if r:
268
+ try:
269
+ data = os.read(master_fd, 4096)
270
+ if not data:
271
+ break
272
+ buffer += data
273
+ # Split and store lines
274
+ while b"\n" in buffer:
275
+ line, buffer = buffer.split(b"\n", 1)
276
+ line_str = line.decode("utf-8", errors="replace")
277
+ with proc._output_lock:
278
+ proc.output.append(line_str)
279
+ if len(proc.output) > proc.max_output_lines:
280
+ proc.output = proc.output[-proc.max_output_lines:]
281
+ except OSError:
282
+ break
283
+
284
+ # Check if process exited
285
+ if proc.process.poll() is not None:
286
+ break
287
+
288
+ # Drain remaining output
289
+ try:
290
+ os.set_blocking(master_fd, False)
291
+ remaining = os.read(master_fd, 65536)
292
+ if remaining:
293
+ for line in remaining.decode("utf-8", errors="replace").split("\n"):
294
+ if line.strip():
295
+ with proc._output_lock:
296
+ proc.output.append(line)
297
+ except OSError:
298
+ pass
299
+
300
+ proc.exit_code = proc.process.returncode
301
+ if proc.exit_code is None:
302
+ proc.process.kill()
303
+ proc.process.wait()
304
+ proc.exit_code = proc.process.returncode or -1
305
+ proc.status = "killed"
306
+ proc.error = f"PTY timed out after {proc.timeout}s"
307
+
308
+ proc.status = "completed" if proc.exit_code == 0 else "error"
309
+
310
+ except Exception as e:
311
+ proc.status = "error"
312
+ proc.error = str(e)[:1000]
313
+ finally:
314
+ os.close(master_fd)
315
+ proc.finished_at = time.time()
316
+
317
+ # ── Process Control ────────────────────────────────────────────
318
+
319
+ def poll(self, session_id: str) -> Process | None:
320
+ """Get process status and any new output."""
321
+ proc = self._processes.get(session_id)
322
+ return proc
323
+
324
+ def wait(self, session_id: str, timeout: float = 60.0) -> Process | None:
325
+ """Block until process completes or timeout."""
326
+ proc = self._processes.get(session_id)
327
+ if not proc:
328
+ return None
329
+
330
+ deadline = time.time() + timeout
331
+ while time.time() < deadline:
332
+ if proc.status in ("completed", "killed", "error"):
333
+ return proc
334
+ time.sleep(0.5)
335
+
336
+ return proc # timed out — return current state
337
+
338
+ def kill(self, session_id: str) -> bool:
339
+ """Kill a running process."""
340
+ proc = self._processes.get(session_id)
341
+ if not proc or not proc.process:
342
+ return False
343
+
344
+ try:
345
+ if proc.pty_fd is not None:
346
+ os.killpg(os.getpgid(proc.process.pid), signal.SIGTERM)
347
+ else:
348
+ proc.process.kill()
349
+ proc.status = "killed"
350
+ proc.exit_code = -9
351
+ proc.finished_at = time.time()
352
+ return True
353
+ except Exception:
354
+ return False
355
+
356
+ def write(self, session_id: str, data: str) -> bool:
357
+ """Send data to process stdin."""
358
+ proc = self._processes.get(session_id)
359
+ if not proc or not proc.process or proc.status != "running":
360
+ return False
361
+
362
+ try:
363
+ if proc.pty_fd is not None:
364
+ os.write(proc.pty_fd, data.encode())
365
+ else:
366
+ proc.process.stdin.write(data)
367
+ proc.process.stdin.flush()
368
+ return True
369
+ except Exception:
370
+ return False
371
+
372
+ def submit(self, session_id: str, data: str) -> bool:
373
+ """Send data + newline to process stdin (answer a prompt)."""
374
+ return self.write(session_id, data + "\n")
375
+
376
+ def close_stdin(self, session_id: str) -> bool:
377
+ """Close stdin (send EOF)."""
378
+ proc = self._processes.get(session_id)
379
+ if not proc or not proc.process:
380
+ return False
381
+
382
+ try:
383
+ if proc.pty_fd is not None:
384
+ # PTY — can't really close stdin, but sending EOF char
385
+ os.write(proc.pty_fd, b"\x04")
386
+ else:
387
+ proc.process.stdin.close()
388
+ return True
389
+ except Exception:
390
+ return False
391
+
392
+ def list_all(self) -> list[dict]:
393
+ """List all processes."""
394
+ with self._lock:
395
+ return [p.to_dict() for p in self._processes.values()]
396
+
397
+ def _cleanup_old(self):
398
+ """Remove old completed processes to free space."""
399
+ old = [
400
+ sid for sid, p in self._processes.items()
401
+ if p.status in ("completed", "killed", "error")
402
+ and p.finished_at
403
+ and time.time() - p.finished_at > 300 # 5 min
404
+ ]
405
+ for sid in old:
406
+ del self._processes[sid]
407
+
408
+
409
+ # Singleton
410
+ terminal = TerminalManager()
watson/toolkit.py ADDED
@@ -0,0 +1,252 @@
1
+ """
2
+ Bellingcat Toolkit Tool — Integrates Bellingcat OSINT tools into Watson.
3
+
4
+ This tool orchestrates OSINT investigations using direct API calls,
5
+ browser scraping, and URL templating. Context-aware deduplication
6
+ via investigation_context parameter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import aiohttp
13
+ import json
14
+ import re
15
+ import uuid
16
+ from urllib.parse import quote
17
+
18
+ import ssl as _ssl
19
+ try:
20
+ import certifi as _certifi
21
+ _SSL_CTX = _ssl.create_default_context(cafile=_certifi.where())
22
+ except ImportError:
23
+ _SSL_CTX = False
24
+
25
+ from src.watson.core.models import Finding, FindingSeverity, FindingSource
26
+ from .toolkit_registry import BellingcatRegistry
27
+ from src.watson.tools.base import OSINTTool
28
+ from src.watson.tools.registry import registry
29
+
30
+
31
+ DIRECT_APIS: dict[str, dict] = {
32
+ "urlscan.io": {"search_url": "https://urlscan.io/api/v1/search/?q={query}", "headers": {"User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)"}, "extract": "results"},
33
+ "crt.sh": {"search_url": "https://crt.sh/?q=%25.{query}&output=json", "headers": {"User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)"}, "extract": "root"},
34
+ "Wayback CDX": {"search_url": "https://web.archive.org/cdx/search/cdx?url=*.{query}/*&output=json&fl=timestamp,original,statuscode&limit=50", "headers": {"User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)"}, "extract": "root"},
35
+ "OpenCorporates": {"search_url": "https://api.opencorporates.com/v0.4/companies/search?q={query}", "headers": {"User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)", "Authorization": "ApiKey {api_key}"}, "extract": "companies", "requires_key": True},
36
+ "OpenSanctions": {"search_url": "https://api.opensanctions.org/search/default?q={query}&limit=10", "headers": {"x-api-key": "{api_key}"}, "extract": "results", "requires_key": True},
37
+ "Wikidata": {"search_url": "https://www.wikidata.org/w/api.php?action=wbsearchentities&search={query}&language=en&format=json&limit=10&origin=*", "headers": {"User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)"}, "extract": "search"},
38
+ "Wikipedia": {"search_url": "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={query}&format=json&origin=*&srlimit=10", "headers": {"User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)"}, "extract": "search"},
39
+ "WhatsMyName": {"search_url": "https://whatsmyname.app/api/v1/search?username={query}", "headers": {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"}, "extract": "sites"},
40
+ "ICIJ Offshore Leaks": {"method": "POST", "search_url": "https://offshoreleaks.icij.org/api/v1/reconcile", "body": {"query": "{query}"}, "headers": {"Content-Type": "application/json"}, "extract": "result"},
41
+ "OCCRP Aleph": {"search_url": "https://aleph.occrp.org/api/2/entities?q={query}&limit=10", "extract": "results"},
42
+ "VirusTotal": {"search_url": "https://www.virustotal.com/api/v3/domains/{query}", "headers": {"x-apikey": "{api_key}"}, "extract": "data", "requires_key": True},
43
+ "BuiltWith": {"search_url": "https://api.builtwith.com/free1/api.json?LOOKUP={query}", "extract": "Results"},
44
+ "Instant Username Search": {"search_url": "https://instantusername.com/api/search?q={query}", "extract": "results"},
45
+ "OpenSky Network": {"search_url": "https://opensky-network.org/api/metadata/aircraft/icao/{query}", "extract": "root"},
46
+ "FlightAware": {"search_url": "https://flightaware.com/live/flight/{query}", "extract": "html"},
47
+ }
48
+
49
+
50
+ class BellingcatToolkit(OSINTTool):
51
+ """Orchestrate Bellingcat OSINT tools against a target."""
52
+
53
+ tool_id = "bellingcat_search"
54
+ name = "Bellingcat Search"
55
+ category = "osint"
56
+ version = "2.0.0"
57
+ description = "Orchestrates Bellingcat OSINT tools with direct API calls, scraping, and URL templating."
58
+ SLOW_TOOLS = {"urlscan.io", "VirusTotal", "FlightAware", "OCCRP Aleph"}
59
+
60
+ def __init__(self, api_keys: dict | None = None, fast_mode: bool = True):
61
+ super().__init__()
62
+ self._api_keys = api_keys or {}
63
+ self._fast_mode = fast_mode
64
+ self._registry = BellingcatRegistry()
65
+ self._investigation_context: dict = {}
66
+ self._called_apis: set[tuple[str, str]] = set() # (api_name, query) — prevents re-running same API+target
67
+
68
+ async def investigate(self, query: str, context: str = "", on_event=None, on_findings=None, investigation_context: dict | None = None) -> list[Finding]:
69
+ """Run the full Bellingcat pipeline. Context-aware deduplication via investigation_context."""
70
+ if investigation_context:
71
+ self._investigation_context = investigation_context
72
+
73
+ def _emit(phase, status, detail="", finding_count=0):
74
+ if on_event:
75
+ on_event("bellingcat_progress", {"phase": phase, "status": status, "detail": detail, "count": finding_count})
76
+
77
+ findings: list[Finding] = []
78
+ try:
79
+ self._registry.load()
80
+ except FileNotFoundError:
81
+ return [self._make_finding(title="Toolkit CSV Not Found", description="Run: curl -sL 'https://github.com/bellingcat/toolkit/releases/download/csv/all-tools.csv' -o data/toolkit.csv", severity=FindingSeverity.LOW)]
82
+
83
+ _emit("classify", "start", detail="Analyzing target")
84
+ target_type = self._classify_target(query, context)
85
+ tools = self._registry.tools_for_target(target_type)
86
+ categories = self._registry.classify(target_type)
87
+ _emit("classify", "complete", detail=f"Classified as {target_type}")
88
+ # Internal plumbing — not an intelligence finding. Don't emit as CONFIRMED.
89
+
90
+ _emit("direct_apis", "start", detail="Running API queries")
91
+ api_findings = await self._run_direct_apis(query, target_type)
92
+ _emit("direct_apis", "complete", detail=f"{len(api_findings)} results")
93
+ findings.extend(api_findings)
94
+
95
+ _emit("automation", "start", detail="Running automated tools")
96
+ auto_findings = await self._run_automation(query, target_type, tools)
97
+ _emit("automation", "complete", detail=f"{len(auto_findings)} automated results")
98
+ findings.extend(auto_findings)
99
+
100
+ _emit("browser", "start", detail="Browser analysis")
101
+ browser_findings = await self._run_browser_scraping(query, target_type, tools)
102
+ _emit("browser", "complete", detail=f"{len(browser_findings)} browser findings")
103
+ findings.extend(browser_findings)
104
+
105
+ _emit("urls", "start", detail="Building URLs")
106
+ url_findings = self._build_url_references(query, target_type, tools)
107
+ _emit("urls", "complete", detail=f"{len(url_findings)} reference links")
108
+ findings.extend(url_findings)
109
+
110
+ findings.append(self._make_finding(title="Bellingcat Complete", description=f"Analyzed {query} across {len(categories)} categories", confidence=0.85))
111
+ return findings
112
+
113
+ def _classify_target(self, query: str, context: str) -> str:
114
+ q = (query + " " + context).lower()
115
+ if re.search(r"@[\w.-]+\.[a-z]{2,}", q): return "email"
116
+ if re.search(r"@\w{2,}", q) or re.search(r"\busername[s]?\b", q): return "username"
117
+ if re.search(r"\b\d{10,}\b", q): return "phone"
118
+ if re.search(r"\.(com|org|net|io|gov|edu|uk|de|fr|ru|cn|jp)\b", q) or re.search(r"^[\w-]+\.[a-z]{2,}$", q.strip()): return "domain"
119
+ if re.search(r"\b(lat|lon|latitude|longitude|coordinates|gps)\b", q): return "location"
120
+ if re.search(r"\b(imo|mmsi|call.?sign|ship|vessel|maritime)\b", q): return "ship"
121
+ if re.search(r"\b(icao|aircraft|flight|airport|airline)\b", q): return "aircraft"
122
+ if re.search(r"\b(company|corp|inc|ltd|llc|business|enterprise|startup|firm|organization|ngo)\b", q): return "company"
123
+ if re.search(r"\b(facebook|instagram|twitter|x\.com|tiktok|youtube|telegram|social)\b", q): return "social_media"
124
+ if len(q.split()) <= 3 and re.search(r"[A-Z][a-z]+ [A-Z][a-z]+", query): return "person"
125
+ return "person"
126
+
127
+ async def _run_direct_apis(self, query: str, target_type: str) -> list[Finding]:
128
+ findings: list[Finding] = []
129
+ relevant_apis = self._select_apis_for_target(target_type, query)
130
+ if not relevant_apis: return findings
131
+ connector = aiohttp.TCPConnector(limit=5, ssl=_SSL_CTX)
132
+ async with aiohttp.ClientSession(connector=connector) as session:
133
+ tasks = [self._call_api(session, name, api_def, query) for name, api_def in relevant_apis.items()]
134
+ results = await asyncio.gather(*tasks, return_exceptions=True)
135
+ for i, result in enumerate(results):
136
+ if isinstance(result, Finding): findings.append(result)
137
+ elif isinstance(result, Exception): findings.append(self._make_finding(title="API Error", description=str(result), severity=FindingSeverity.LOW))
138
+ return findings
139
+
140
+ def _select_apis_for_target(self, target_type: str, query: str = "") -> dict:
141
+ api_map = {
142
+ "person": ["Wikidata", "Wikipedia", "ICIJ Offshore Leaks", "OpenSanctions"],
143
+ "company": ["OpenCorporates", "Wikidata", "Wikipedia", "ICIJ Offshore Leaks", "OCCRP Aleph", "Wayback CDX", "crt.sh"],
144
+ "domain": ["crt.sh", "urlscan.io", "Wayback CDX", "VirusTotal", "BuiltWith", "Wikipedia", "Wikidata"],
145
+ "email": ["ICIJ Offshore Leaks", "OCCRP Aleph"],
146
+ "organization": ["OpenCorporates", "Wikidata", "ICIJ Offshore Leaks", "OCCRP Aleph"],
147
+ "username": ["WhatsMyName", "Instant Username Search"],
148
+ "location": ["Wikidata", "Wikipedia"],
149
+ "ship": [], "aircraft": ["OpenSky Network", "FlightAware"], "vehicle": [],
150
+ }
151
+ names = api_map.get(target_type, [])
152
+ # Dedup: skip (api_name, query) already called this session
153
+ names = [n for n in names if (n.lower(), query.lower().strip()) not in self._called_apis]
154
+ result = {}
155
+ for n in names:
156
+ if n not in DIRECT_APIS: continue
157
+ if self._fast_mode and n in self.SLOW_TOOLS: continue
158
+ api_def = DIRECT_APIS[n]
159
+ if not api_def.get("requires_key") or self._api_keys.get(n.lower().replace(" ", "_")):
160
+ result[n] = api_def
161
+ return result
162
+
163
+ async def _call_api(self, session, name: str, api_def: dict, query: str, max_retries: int = 2) -> Finding:
164
+ url = api_def["search_url"].format(query=quote(query, safe=""))
165
+ headers = dict(api_def.get("headers", {}))
166
+ self._called_apis.add((name.lower(), query.lower().strip()))
167
+ if api_def.get("requires_key"):
168
+ key = self._api_keys.get(name.lower().replace(" ", "_"), "")
169
+ if not key: return self._make_finding(title=f"{name} (API Key Required)", description=f"Set API key for {name}.", evidence=[url], confidence=0.1, severity=FindingSeverity.INFO)
170
+ headers = {k: v.format(api_key=key) for k, v in headers.items()}
171
+ for attempt in range(max_retries + 1):
172
+ try:
173
+ method = api_def.get("method", "GET")
174
+ if method == "POST":
175
+ body_template = api_def.get("body", {})
176
+ body = json.loads(json.dumps(body_template).replace("{query}", query)) if body_template else None
177
+ async with session.post(url, headers=headers, json=body, ssl=_SSL_CTX) as resp:
178
+ if resp.status in (200, 201):
179
+ data = await resp.json()
180
+ raw_items = self._extract_items(data, api_def.get("extract", "root"))
181
+ items = [json.dumps(i) if isinstance(i, dict) else str(i) for i in raw_items]
182
+ return self._make_finding(title=f"{name} Results", description=f"Found {len(items)} items", evidence=items[:5], confidence=0.7, source_tool=name)
183
+ elif resp.status == 429: await asyncio.sleep(2 ** attempt)
184
+ else: return self._make_finding(title=f"{name} Error", description=f"HTTP {resp.status}", evidence=[url], confidence=0.1, severity=FindingSeverity.LOW, source_tool=name)
185
+ else:
186
+ async with session.get(url, headers=headers, ssl=_SSL_CTX) as resp:
187
+ if resp.status == 200:
188
+ data = await resp.json()
189
+ raw_items = self._extract_items(data, api_def.get("extract", "root"))
190
+ items = [json.dumps(i) if isinstance(i, dict) else str(i) for i in raw_items]
191
+ return self._make_finding(title=f"{name} Results", description=f"Found {len(items)} items", evidence=items[:5], confidence=0.7, source_tool=name)
192
+ elif resp.status == 429: await asyncio.sleep(2 ** attempt)
193
+ else: return self._make_finding(title=f"{name} Error", description=f"HTTP {resp.status}", evidence=[url], confidence=0.1, severity=FindingSeverity.LOW, source_tool=name)
194
+ except Exception as e:
195
+ if attempt == max_retries: return self._make_finding(title=f"{name} Failed", description=str(e)[:200], confidence=0.0, severity=FindingSeverity.LOW, source_tool=name)
196
+ await asyncio.sleep(1)
197
+ return self._make_finding(title=f"{name} Timeout", description="All retries exhausted", confidence=0.0, severity=FindingSeverity.LOW)
198
+
199
+ @staticmethod
200
+ def _extract_items(data, key: str) -> list:
201
+ if isinstance(data, list): return data[:5]
202
+ if isinstance(data, dict):
203
+ if key == "root": return [data]
204
+ parts = key.split(".")
205
+ current = data
206
+ for part in parts:
207
+ if isinstance(current, dict): current = current.get(part, [])
208
+ else: return []
209
+ if isinstance(current, list): return current[:5]
210
+ if isinstance(current, dict): return list(current.values())[:5]
211
+ return []
212
+
213
+ async def _run_automation(self, query: str, target_type: str, tools: list) -> list[Finding]:
214
+ try:
215
+ from watson.toolkit_automation import BellingcatAutomation, TARGET_API_MAP
216
+ except ImportError as e:
217
+ return [self._make_finding(title="Bellingcat unavailable", description=f"Automation module not found: {e}", confidence=0.0, severity=FindingSeverity.INFO, source_tool="bellingcat")]
218
+ findings: list[Finding] = []
219
+ auto_tool_names = TARGET_API_MAP.get(target_type, [])
220
+ if not auto_tool_names: return findings
221
+ automation = BellingcatAutomation(api_keys=self._api_keys)
222
+ results = await automation.run_category(target_type, query, auto_tool_names)
223
+ for tool_name, result_list in results.items():
224
+ if not result_list: continue
225
+ rich_findings = automation.results_to_findings(tool_name, result_list, query, target_type)
226
+ for rf in rich_findings:
227
+ metadata_kwargs = {k: v for k, v in rf.items() if k not in ("title", "description", "evidence", "confidence", "severity", "tool", "url")}
228
+ findings.append(self._make_finding(title=rf.get("title", f"{tool_name}: Result")[:200], description=rf.get("description", "")[:500], evidence=rf.get("evidence", []) or [], confidence=float(rf.get("confidence", 0.5)), severity=FindingSeverity.INFO if rf.get("severity") not in [s.value for s in FindingSeverity] else FindingSeverity(rf.get("severity")), source_tool=tool_name, **metadata_kwargs))
229
+ return findings
230
+
231
+ async def _run_browser_scraping(self, query: str, target_type: str, tools: list) -> list[Finding]:
232
+ return []
233
+
234
+ def _build_url_references(self, query: str, target_type: str, tools: list) -> list[Finding]:
235
+ return []
236
+
237
+ def _make_finding(self, title: str = "", description: str = "", evidence: list = None,
238
+ confidence: float = 0.5, severity=None, source_tool: str = "", **kwargs) -> Finding:
239
+ return Finding(
240
+ id=f"bcat-{uuid.uuid4().hex[:12]}",
241
+ title=title, description=(description or "")[:2000],
242
+ evidence=evidence or [], confidence=confidence,
243
+ severity=severity or FindingSeverity.INFO,
244
+ source=FindingSource.BELLINGCAT if source_tool else FindingSource.OSINT,
245
+ tool=source_tool,
246
+ metadata={"bellingcat": True, **kwargs},
247
+ )
248
+
249
+
250
+ # Register with the tool registry
251
+ bellingcat_tool = BellingcatToolkit()
252
+ registry.register(bellingcat_tool)