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
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Geolocation tool — pinpoint locations from visual clues and coordinates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import OSINTTool
|
|
6
|
+
from .registry import registry
|
|
7
|
+
from ..core.models import Finding, FindingSource
|
|
8
|
+
from ..utils.http import get_client
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("watson.geolocation")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GeolocationTool(OSINTTool):
|
|
16
|
+
"""Investigate and verify locations — geocoding, reverse geocoding, and POI search."""
|
|
17
|
+
|
|
18
|
+
category = FindingSource.GEOLOCATION
|
|
19
|
+
name = "geolocation"
|
|
20
|
+
description = "Forward/reverse geocoding, POI search, address verification"
|
|
21
|
+
free_tier_available = True
|
|
22
|
+
rate_limit_rps = 1.0
|
|
23
|
+
|
|
24
|
+
NOMINATIM_SEARCH = "https://nominatim.openstreetmap.org/search"
|
|
25
|
+
NOMINATIM_REVERSE = "https://nominatim.openstreetmap.org/reverse"
|
|
26
|
+
OVERPASS_API = "https://overpass-api.de/api/interpreter"
|
|
27
|
+
|
|
28
|
+
async def _overpass_query(self, query: str) -> dict | None:
|
|
29
|
+
"""Run an Overpass QL query via POST (required for larger queries)."""
|
|
30
|
+
import httpx
|
|
31
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
32
|
+
r = await client.post(
|
|
33
|
+
self.OVERPASS_API,
|
|
34
|
+
data={"data": query},
|
|
35
|
+
headers={"Accept": "application/json", "User-Agent": "WatsonOSINT/0.3"},
|
|
36
|
+
)
|
|
37
|
+
r.raise_for_status()
|
|
38
|
+
return r.json()
|
|
39
|
+
|
|
40
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
41
|
+
findings: list[Finding] = []
|
|
42
|
+
client = get_client(rate_limit=self.rate_limit_rps)
|
|
43
|
+
|
|
44
|
+
# Check for coordinates in the query
|
|
45
|
+
coords = self._parse_coordinates(query)
|
|
46
|
+
|
|
47
|
+
if coords:
|
|
48
|
+
lat, lon = coords
|
|
49
|
+
try:
|
|
50
|
+
params = {"lat": lat, "lon": lon, "format": "json", "zoom": 18}
|
|
51
|
+
data = await client.get_json(self.NOMINATIM_REVERSE, params=params)
|
|
52
|
+
|
|
53
|
+
if isinstance(data, dict):
|
|
54
|
+
display = data.get("display_name", f"{lat}, {lon}")
|
|
55
|
+
address = data.get("address", {})
|
|
56
|
+
|
|
57
|
+
findings.append(
|
|
58
|
+
self._make_finding(
|
|
59
|
+
title=f"📍 Reverse geocode: {display[:80]}",
|
|
60
|
+
description=(
|
|
61
|
+
f"Coordinates {lat}, {lon} resolve to: "
|
|
62
|
+
f"{address.get('road', '')} {address.get('city', '')}, "
|
|
63
|
+
f"{address.get('country', '')}"
|
|
64
|
+
),
|
|
65
|
+
evidence=[f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=18"],
|
|
66
|
+
confidence=0.95,
|
|
67
|
+
lat=lat,
|
|
68
|
+
lon=lon,
|
|
69
|
+
address=address,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Find nearby POIs via Overpass
|
|
74
|
+
pois = await self._nearby_pois(lat, lon, client)
|
|
75
|
+
findings.extend(pois)
|
|
76
|
+
|
|
77
|
+
except Exception as e:
|
|
78
|
+
findings.append(
|
|
79
|
+
self._make_finding(
|
|
80
|
+
title="Geolocation lookup failed",
|
|
81
|
+
description=f"Error reverse-geocoding {lat}, {lon}: {str(e)}",
|
|
82
|
+
confidence=0.0,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
else:
|
|
87
|
+
# Try forward geocoding — search for a place name
|
|
88
|
+
location = self._extract_place(query)
|
|
89
|
+
if location:
|
|
90
|
+
try:
|
|
91
|
+
params = {"q": location, "format": "json", "limit": 3}
|
|
92
|
+
data = await client.get_json(self.NOMINATIM_SEARCH, params=params)
|
|
93
|
+
|
|
94
|
+
if isinstance(data, list) and data:
|
|
95
|
+
places = []
|
|
96
|
+
for d in data[:3]:
|
|
97
|
+
places.append(f"{d.get('display_name', '')[:60]} ({d.get('lat')}, {d.get('lon')})")
|
|
98
|
+
|
|
99
|
+
findings.append(
|
|
100
|
+
self._make_finding(
|
|
101
|
+
title=f"📍 Places matching '{location}'",
|
|
102
|
+
description="\n".join(f"- {p}" for p in places),
|
|
103
|
+
confidence=0.85,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
return findings
|
|
110
|
+
|
|
111
|
+
def _parse_coordinates(self, text: str) -> tuple[float, float] | None:
|
|
112
|
+
"""Extract lat/lon from text."""
|
|
113
|
+
import re
|
|
114
|
+
|
|
115
|
+
# Decimal degrees: 48.8566, 2.3522
|
|
116
|
+
match = re.search(r"(-?\d+\.\d+)\s*[,;\s]\s*(-?\d+\.\d+)", text)
|
|
117
|
+
if match:
|
|
118
|
+
return float(match.group(1)), float(match.group(2))
|
|
119
|
+
|
|
120
|
+
# DMS format: 48°51'24"N, 2°21'08"E — simplified
|
|
121
|
+
match = re.search(
|
|
122
|
+
r"(\d+)°\s*(\d+)'?\s*(\d+(?:\.\d+)?)\"?\s*([NS])[\s,;]+\s*(\d+)°\s*(\d+)'?\s*(\d+(?:\.\d+)?)\"?\s*([EW])",
|
|
123
|
+
text,
|
|
124
|
+
)
|
|
125
|
+
if match:
|
|
126
|
+
lat = int(match.group(1)) + int(match.group(2)) / 60 + float(match.group(3)) / 3600
|
|
127
|
+
if match.group(4) == "S":
|
|
128
|
+
lat = -lat
|
|
129
|
+
lon = int(match.group(5)) + int(match.group(6)) / 60 + float(match.group(7)) / 3600
|
|
130
|
+
if match.group(8) == "W":
|
|
131
|
+
lon = -lon
|
|
132
|
+
return lat, lon
|
|
133
|
+
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
async def _nearby_pois(self, lat: float, lon: float, client) -> list[Finding]:
|
|
137
|
+
"""Find points of interest near a location using Overpass API."""
|
|
138
|
+
findings: list[Finding] = []
|
|
139
|
+
radius = 500 # meters
|
|
140
|
+
|
|
141
|
+
query = f"""
|
|
142
|
+
[out:json];
|
|
143
|
+
(
|
|
144
|
+
node(around:{radius},{lat},{lon})["amenity"];
|
|
145
|
+
node(around:{radius},{lat},{lon})["tourism"];
|
|
146
|
+
node(around:{radius},{lat},{lon})["historic"];
|
|
147
|
+
node(around:{radius},{lat},{lon})["building"]["name"];
|
|
148
|
+
);
|
|
149
|
+
out 5;
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
data = await self._overpass_query(query)
|
|
154
|
+
|
|
155
|
+
if isinstance(data, dict) and "elements" in data:
|
|
156
|
+
elements = data["elements"][:5]
|
|
157
|
+
if elements:
|
|
158
|
+
pois = []
|
|
159
|
+
for el in elements:
|
|
160
|
+
tags = el.get("tags", {})
|
|
161
|
+
name = tags.get("name", "Unnamed")
|
|
162
|
+
amenity = tags.get("amenity", tags.get("tourism", tags.get("historic", "")))
|
|
163
|
+
pois.append(f"{name} ({amenity})" if amenity else name)
|
|
164
|
+
|
|
165
|
+
findings.append(
|
|
166
|
+
self._make_finding(
|
|
167
|
+
title=f"🏛 Nearby landmarks ({len(elements)} found)",
|
|
168
|
+
description="\n".join(f"- {p}" for p in pois),
|
|
169
|
+
confidence=0.8,
|
|
170
|
+
lat=lat,
|
|
171
|
+
lon=lon,
|
|
172
|
+
radius=radius,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
return findings
|
|
179
|
+
|
|
180
|
+
def _extract_place(self, text: str) -> str | None:
|
|
181
|
+
"""Extract a place name from query text."""
|
|
182
|
+
import re
|
|
183
|
+
|
|
184
|
+
patterns = [
|
|
185
|
+
r"(?:find|locate|search|show|look up|geolocate)\s+(.+?)(?:\s+(?:and|or|for|$|\.))",
|
|
186
|
+
r"(?:where is|what is at)\s+(.+?)(?:\?|$)",
|
|
187
|
+
]
|
|
188
|
+
for pattern in patterns:
|
|
189
|
+
match = re.search(pattern, text, re.IGNORECASE)
|
|
190
|
+
if match:
|
|
191
|
+
return match.group(1).strip().rstrip(".,?")
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
async def investigate_infrastructure(self, place_name: str) -> list[Finding]:
|
|
195
|
+
"""Geocode a place then find nearby strategic infrastructure.
|
|
196
|
+
|
|
197
|
+
Queries ports, mines, refineries, military facilities — relevant for
|
|
198
|
+
sanctions evasion, conflict mineral smuggling, and commodity fraud investigations.
|
|
199
|
+
"""
|
|
200
|
+
findings: list[Finding] = []
|
|
201
|
+
client = get_client(rate_limit=self.rate_limit_rps)
|
|
202
|
+
|
|
203
|
+
# Step 1: Geocode the place
|
|
204
|
+
try:
|
|
205
|
+
params = {"q": place_name, "format": "json", "limit": 1}
|
|
206
|
+
data = await client.get_json(self.NOMINATIM_SEARCH, params=params)
|
|
207
|
+
if not isinstance(data, list) or not data:
|
|
208
|
+
return findings
|
|
209
|
+
|
|
210
|
+
lat = float(data[0]["lat"])
|
|
211
|
+
lon = float(data[0]["lon"])
|
|
212
|
+
display = data[0].get("display_name", place_name)[:80]
|
|
213
|
+
|
|
214
|
+
findings.append(self._make_finding(
|
|
215
|
+
title=f"📍 Geocoded: {display}",
|
|
216
|
+
description=f"Coordinates: {lat}, {lon}\n[OpenStreetMap](https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=12)",
|
|
217
|
+
evidence=[f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=12"],
|
|
218
|
+
confidence=0.95,
|
|
219
|
+
lat=lat, lon=lon,
|
|
220
|
+
))
|
|
221
|
+
|
|
222
|
+
# Step 2: Find infrastructure within 50km
|
|
223
|
+
infra = await self._nearby_infrastructure(lat, lon, client)
|
|
224
|
+
findings.extend(infra)
|
|
225
|
+
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
return findings
|
|
230
|
+
|
|
231
|
+
async def _nearby_infrastructure(self, lat: float, lon: float, client) -> list[Finding]:
|
|
232
|
+
"""Query Overpass for strategic infrastructure near a point.
|
|
233
|
+
|
|
234
|
+
Queries nodes, ways, AND relations for industrial/mining/military/port
|
|
235
|
+
features. Nornickel-type targets need ways (mine boundaries) and relations.
|
|
236
|
+
"""
|
|
237
|
+
findings: list[Finding] = []
|
|
238
|
+
radius = 50000 # 50km radius
|
|
239
|
+
|
|
240
|
+
# ── Single combined query: nodes + ways + relations for all categories ──
|
|
241
|
+
# This avoids 4 sequential API calls (4×1.5s = 6s saved) and catches
|
|
242
|
+
# ways/relations that were invisible to node-only queries.
|
|
243
|
+
query = f"""[out:json][timeout:25];
|
|
244
|
+
(
|
|
245
|
+
// Mines & quarries — nodes, ways, relations
|
|
246
|
+
node(around:{radius},{lat},{lon})["landuse"="quarry"];
|
|
247
|
+
way(around:{radius},{lat},{lon})["landuse"="quarry"];
|
|
248
|
+
rel(around:{radius},{lat},{lon})["landuse"="quarry"];
|
|
249
|
+
node(around:{radius},{lat},{lon})["landuse"="mining"];
|
|
250
|
+
way(around:{radius},{lat},{lon})["landuse"="mining"];
|
|
251
|
+
rel(around:{radius},{lat},{lon})["landuse"="mining"];
|
|
252
|
+
|
|
253
|
+
// Industrial facilities — factories, refineries, smelters
|
|
254
|
+
node(around:{radius},{lat},{lon})["landuse"="industrial"];
|
|
255
|
+
way(around:{radius},{lat},{lon})["landuse"="industrial"];
|
|
256
|
+
rel(around:{radius},{lat},{lon})["landuse"="industrial"];
|
|
257
|
+
node(around:{radius},{lat},{lon})["man_made"="works"];
|
|
258
|
+
way(around:{radius},{lat},{lon})["man_made"="works"];
|
|
259
|
+
node(around:{radius},{lat},{lon})["industrial"];
|
|
260
|
+
way(around:{radius},{lat},{lon})["industrial"];
|
|
261
|
+
|
|
262
|
+
// Ports & harbours
|
|
263
|
+
node(around:{radius},{lat},{lon})["amenity"="ferry_terminal"];
|
|
264
|
+
node(around:{radius},{lat},{lon})["harbour"];
|
|
265
|
+
way(around:{radius},{lat},{lon})["harbour"];
|
|
266
|
+
|
|
267
|
+
// Military
|
|
268
|
+
node(around:{radius},{lat},{lon})["landuse"="military"];
|
|
269
|
+
way(around:{radius},{lat},{lon})["landuse"="military"];
|
|
270
|
+
node(around:{radius},{lat},{lon})["aeroway"="aerodrome"]["name"];
|
|
271
|
+
);
|
|
272
|
+
out center 20;
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
loc_label = display_name(lat, lon)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
data = await self._overpass_query(query)
|
|
279
|
+
|
|
280
|
+
if isinstance(data, dict) and "elements" in data:
|
|
281
|
+
elements = data["elements"]
|
|
282
|
+
# Group by category
|
|
283
|
+
mines: list[str] = []
|
|
284
|
+
industrial: list[str] = []
|
|
285
|
+
ports: list[str] = []
|
|
286
|
+
military: list[str] = []
|
|
287
|
+
|
|
288
|
+
for el in elements[:20]:
|
|
289
|
+
tags = el.get("tags", {})
|
|
290
|
+
name = tags.get("name", "")
|
|
291
|
+
# Get center for ways/relations
|
|
292
|
+
center = el.get("center", {})
|
|
293
|
+
el_lat = center.get("lat", el.get("lat", 0))
|
|
294
|
+
el_lon = center.get("lon", el.get("lon", 0))
|
|
295
|
+
elem_type = el.get("type", "node")
|
|
296
|
+
|
|
297
|
+
label = f"- **{name}**" if name else f"- Unnamed {elem_type}"
|
|
298
|
+
if el_lat and el_lon:
|
|
299
|
+
label += f" ({el_lat:.4f}, {el_lon:.4f})"
|
|
300
|
+
|
|
301
|
+
# Classify into category
|
|
302
|
+
landuse = tags.get("landuse", "")
|
|
303
|
+
man_made = tags.get("man_made", "")
|
|
304
|
+
industrial_tag = tags.get("industrial", "")
|
|
305
|
+
amenity = tags.get("amenity", "")
|
|
306
|
+
aeroway = tags.get("aeroway", "")
|
|
307
|
+
harbour_tag = tags.get("harbour", "")
|
|
308
|
+
has_name = tags.get("name", "")
|
|
309
|
+
|
|
310
|
+
if landuse in ("quarry", "mining"):
|
|
311
|
+
mines.append(label)
|
|
312
|
+
elif landuse == "industrial" or man_made == "works" or industrial_tag:
|
|
313
|
+
industrial.append(label)
|
|
314
|
+
elif amenity == "ferry_terminal" or harbour_tag:
|
|
315
|
+
ports.append(label)
|
|
316
|
+
elif landuse == "military" or (aeroway == "aerodrome" and has_name):
|
|
317
|
+
military.append(label)
|
|
318
|
+
|
|
319
|
+
titles = {
|
|
320
|
+
"mines": "⛏ Mines & quarries",
|
|
321
|
+
"industrial": "🏭 Industrial facilities",
|
|
322
|
+
"ports": "🚢 Ports & harbours",
|
|
323
|
+
"military": "🪖 Military installations",
|
|
324
|
+
}
|
|
325
|
+
for cat, entries in [("mines", mines), ("industrial", industrial),
|
|
326
|
+
("ports", ports), ("military", military)]:
|
|
327
|
+
if entries:
|
|
328
|
+
findings.append(self._make_finding(
|
|
329
|
+
title=f"{titles[cat]} near {loc_label}",
|
|
330
|
+
description="\n".join(entries[:6]),
|
|
331
|
+
confidence=0.85,
|
|
332
|
+
infrastructure_type=cat,
|
|
333
|
+
))
|
|
334
|
+
|
|
335
|
+
except Exception as e:
|
|
336
|
+
logger.warning("overpass_infra_failed: %s", e)
|
|
337
|
+
|
|
338
|
+
return findings
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def display_name(lat: float, lon: float) -> str:
|
|
342
|
+
return f"({lat:.3f}, {lon:.3f})"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Register
|
|
346
|
+
geolocation_tool = GeolocationTool()
|
|
347
|
+
registry.register(geolocation_tool)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Image & Video verification tool — reverse image search, EXIF, verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from .base import OSINTTool
|
|
9
|
+
from .registry import registry
|
|
10
|
+
from ..core.models import Finding, FindingSource
|
|
11
|
+
from ..utils.http import get_client, BaseHTTPClient
|
|
12
|
+
from ..utils.helpers import is_url
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ImageVideoTool(OSINTTool):
|
|
16
|
+
"""Reverse image search, EXIF extraction, and visual verification."""
|
|
17
|
+
|
|
18
|
+
category = FindingSource.IMAGE_VIDEO
|
|
19
|
+
name = "image-video"
|
|
20
|
+
description = "Reverse image search (Google, Yandex, TinEye), EXIF metadata, verification"
|
|
21
|
+
free_tier_available = True
|
|
22
|
+
rate_limit_rps = 0.5
|
|
23
|
+
|
|
24
|
+
GOOGLE_IMAGE_SEARCH = "https://lens.google.com/uploadbyurl?url={image_url}"
|
|
25
|
+
YANDEX_IMAGE_SEARCH = "https://yandex.com/images/search?rpt=imageview&url={image_url}"
|
|
26
|
+
TINEYE_SEARCH = "https://tineye.com/search?url={image_url}"
|
|
27
|
+
|
|
28
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
29
|
+
findings: list[Finding] = []
|
|
30
|
+
|
|
31
|
+
# Extract image URLs from the query
|
|
32
|
+
image_urls = self._extract_image_urls(query)
|
|
33
|
+
|
|
34
|
+
if image_urls:
|
|
35
|
+
for url in image_urls[:3]: # Max 3 images
|
|
36
|
+
findings.append(
|
|
37
|
+
self._make_finding(
|
|
38
|
+
title=f"🖼 Reverse image search ready: {url[:60]}",
|
|
39
|
+
description=(
|
|
40
|
+
f"Submit this image to reverse image search engines:\n"
|
|
41
|
+
f"- [Google Lens]({self.GOOGLE_IMAGE_SEARCH.format(image_url=quote(url))})\n"
|
|
42
|
+
f"- [Yandex]({self.YANDEX_IMAGE_SEARCH.format(image_url=quote(url))})\n"
|
|
43
|
+
f"- [TinEye]({self.TINEYE_SEARCH.format(image_url=quote(url))})\n\n"
|
|
44
|
+
f"Tip: Yandex is often best for faces and Eastern European content. "
|
|
45
|
+
f"Google Lens excels at objects and locations."
|
|
46
|
+
),
|
|
47
|
+
evidence=[
|
|
48
|
+
self.GOOGLE_IMAGE_SEARCH.format(image_url=quote(url)),
|
|
49
|
+
self.YANDEX_IMAGE_SEARCH.format(image_url=quote(url)),
|
|
50
|
+
self.TINEYE_SEARCH.format(image_url=quote(url)),
|
|
51
|
+
],
|
|
52
|
+
confidence=0.7,
|
|
53
|
+
image_url=url,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Check for image/video file references even without full URLs
|
|
58
|
+
if not image_urls:
|
|
59
|
+
image_refs = self._find_image_references(query)
|
|
60
|
+
if image_refs:
|
|
61
|
+
findings.append(
|
|
62
|
+
self._make_finding(
|
|
63
|
+
title="📸 Image/video reference detected",
|
|
64
|
+
description=(
|
|
65
|
+
f"Found references to: {', '.join(image_refs[:5])}. "
|
|
66
|
+
"To run reverse image search, provide the full image URL. "
|
|
67
|
+
"Use `watson investigate \"[image_url]\"` with the direct URL."
|
|
68
|
+
),
|
|
69
|
+
confidence=0.4,
|
|
70
|
+
references=image_refs,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return findings
|
|
75
|
+
|
|
76
|
+
def _extract_image_urls(self, text: str) -> list[str]:
|
|
77
|
+
"""Extract image URLs from text."""
|
|
78
|
+
urls = re.findall(
|
|
79
|
+
r"https?://[^\s]+?\.(?:jpg|jpeg|png|gif|webp|bmp|svg)(?:\?[^\s]*)?",
|
|
80
|
+
text,
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
# Also catch URLs that might be images without extensions
|
|
84
|
+
if not urls:
|
|
85
|
+
for word in text.split():
|
|
86
|
+
if word.startswith("http") and any(
|
|
87
|
+
kw in word.lower() for kw in ["image", "photo", "img", "pic"]
|
|
88
|
+
):
|
|
89
|
+
urls.append(word.strip(".,;:!?"))
|
|
90
|
+
return urls
|
|
91
|
+
|
|
92
|
+
def _find_image_references(self, text: str) -> list[str]:
|
|
93
|
+
"""Find references to images/videos in text."""
|
|
94
|
+
keywords = ["photo", "image", "video", "screenshot", "picture", "footage"]
|
|
95
|
+
refs = []
|
|
96
|
+
for kw in keywords:
|
|
97
|
+
idx = text.lower().find(kw)
|
|
98
|
+
if idx >= 0:
|
|
99
|
+
# Get surrounding context
|
|
100
|
+
start = max(0, idx - 20)
|
|
101
|
+
end = min(len(text), idx + len(kw) + 30)
|
|
102
|
+
refs.append(text[start:end].strip())
|
|
103
|
+
return refs
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Register
|
|
107
|
+
image_video_tool = ImageVideoTool()
|
|
108
|
+
registry.register(image_video_tool)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""MarineTraffic AIS tool — ship tracking for sanctions evasion OSINT.
|
|
2
|
+
|
|
3
|
+
Monitors vessel movements, ownership, and port calls. Critical for
|
|
4
|
+
sanctions evasion, illegal fishing, and smuggling investigations.
|
|
5
|
+
Paid API required (free tier: 500 req/day).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from .base import OSINTTool
|
|
13
|
+
from .registry import registry
|
|
14
|
+
from ..core.models import Finding, FindingSource
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("watson.marinetraffic")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MarineTrafficTool(OSINTTool):
|
|
20
|
+
"""Ship tracking via AIS — vessel movements, ownership, port calls."""
|
|
21
|
+
|
|
22
|
+
category = FindingSource.OSINT
|
|
23
|
+
name = "marinetraffic"
|
|
24
|
+
description = "MarineTraffic AIS — vessel tracking, ship ownership, port calls for sanctions evasion detection"
|
|
25
|
+
free_tier_available = False
|
|
26
|
+
rate_limit_rps = 1.0
|
|
27
|
+
|
|
28
|
+
VESSEL_SEARCH = "https://services.marinetraffic.com/api/exportvessels/v:8/"
|
|
29
|
+
|
|
30
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
31
|
+
"""Search vessel movements related to a target."""
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
|
|
34
|
+
from watson.api_keys import get_key
|
|
35
|
+
|
|
36
|
+
api_key = get_key("marinetraffic")
|
|
37
|
+
if not api_key:
|
|
38
|
+
findings.append(self._make_finding(
|
|
39
|
+
title="🔒 MarineTraffic AIS API key not configured",
|
|
40
|
+
description=(
|
|
41
|
+
"MarineTraffic provides real-time ship tracking via AIS — vessel "
|
|
42
|
+
"movements, ownership, port calls. Critical for sanctions evasion "
|
|
43
|
+
"and smuggling investigations.\n\n"
|
|
44
|
+
"Install your API key in Settings → API Vault to enable.\n"
|
|
45
|
+
"Get a key: https://www.marinetraffic.com/en/ais-api-services"
|
|
46
|
+
),
|
|
47
|
+
confidence=0.0,
|
|
48
|
+
source_url="https://www.marinetraffic.com/en/ais-api-services",
|
|
49
|
+
))
|
|
50
|
+
return findings
|
|
51
|
+
|
|
52
|
+
# Extract searchable terms
|
|
53
|
+
vessel_name = self._extract_vessel_name(query)
|
|
54
|
+
if not vessel_name:
|
|
55
|
+
return findings
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
import httpx
|
|
59
|
+
|
|
60
|
+
async with httpx.AsyncClient(timeout=20) as client:
|
|
61
|
+
resp = await client.get(
|
|
62
|
+
f"{self.VESSEL_SEARCH}{api_key}/v:2/name:{vessel_name}/protocol:jsono",
|
|
63
|
+
)
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
|
|
66
|
+
# MarineTraffic returns JSONP-style — strip prefix
|
|
67
|
+
raw = resp.text
|
|
68
|
+
if raw.startswith("jsono("):
|
|
69
|
+
import json as _json
|
|
70
|
+
raw = raw[6:-1] # strip jsono( ... )
|
|
71
|
+
data = _json.loads(raw)
|
|
72
|
+
else:
|
|
73
|
+
data = resp.json()
|
|
74
|
+
|
|
75
|
+
vessels = data if isinstance(data, list) else data.get("data", {}).get("rows", [])
|
|
76
|
+
|
|
77
|
+
if vessels:
|
|
78
|
+
entries = []
|
|
79
|
+
for v in vessels[:5]:
|
|
80
|
+
name = v.get("SHIPNAME", v.get("NAME", "Unknown"))
|
|
81
|
+
imo = v.get("IMO", "?")
|
|
82
|
+
mmsi = v.get("MMSI", "?")
|
|
83
|
+
flag = v.get("FLAG", v.get("COUNTRY", "?"))
|
|
84
|
+
ship_type = v.get("SHIPTYPE", v.get("TYPE", "?"))
|
|
85
|
+
lat = v.get("LAT", "")
|
|
86
|
+
lon = v.get("LON", "")
|
|
87
|
+
|
|
88
|
+
entries.append(
|
|
89
|
+
f"- **{name}** (IMO: {imo}) — {flag} flag\n"
|
|
90
|
+
f" Type: {ship_type} | MMSI: {mmsi}"
|
|
91
|
+
+ (f" | Position: {lat}, {lon}" if lat and lon else "")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
findings.append(self._make_finding(
|
|
95
|
+
title=f"🚢 MarineTraffic: {len(vessels)} vessels matching '{vessel_name}'",
|
|
96
|
+
description="\n".join(entries[:5]),
|
|
97
|
+
confidence=0.85,
|
|
98
|
+
evidence=[f"https://www.marinetraffic.com/en/ais/details/ships/name:{vessel_name}"],
|
|
99
|
+
))
|
|
100
|
+
else:
|
|
101
|
+
findings.append(self._make_finding(
|
|
102
|
+
title=f"🚢 MarineTraffic: No vessels found for '{vessel_name}'",
|
|
103
|
+
description="No vessels matching the query in the AIS database.",
|
|
104
|
+
confidence=0.6,
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.warning("marinetraffic_search_failed: %s", e)
|
|
109
|
+
findings.append(self._make_finding(
|
|
110
|
+
title=f"⚠ MarineTraffic search failed: {str(e)[:100]}",
|
|
111
|
+
description="API call failed. Check your key or try again later.",
|
|
112
|
+
confidence=0.0,
|
|
113
|
+
))
|
|
114
|
+
|
|
115
|
+
return findings
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _extract_vessel_name(query: str) -> str | None:
|
|
119
|
+
"""Extract vessel name or IMO number from query."""
|
|
120
|
+
import re
|
|
121
|
+
|
|
122
|
+
# IMO number: 7 digits
|
|
123
|
+
imo = re.search(r'\b(\d{7})\b', query)
|
|
124
|
+
if imo:
|
|
125
|
+
return imo.group(1)
|
|
126
|
+
|
|
127
|
+
# Vessel name in quotes
|
|
128
|
+
quoted = re.search(r'"([^"]+)"', query)
|
|
129
|
+
if quoted:
|
|
130
|
+
return quoted.group(1)
|
|
131
|
+
|
|
132
|
+
# Vessel name keyword
|
|
133
|
+
vessel = re.search(r'(?:ship|vessel|tanker|vessel_name)\s*[:=]?\s*([A-Za-z0-9\s]{2,30})', query, re.IGNORECASE)
|
|
134
|
+
if vessel:
|
|
135
|
+
return vessel.group(1).strip()
|
|
136
|
+
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Register
|
|
141
|
+
marinetraffic_tool = MarineTrafficTool()
|
|
142
|
+
registry.register(marinetraffic_tool)
|