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