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,107 @@
|
|
|
1
|
+
"""Conflict monitoring tool — incident data, live conflict maps, event aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from .base import OSINTTool
|
|
8
|
+
from .registry import registry
|
|
9
|
+
from ..core.models import Finding, FindingSource
|
|
10
|
+
from ..utils.http import get_client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConflictTool(OSINTTool):
|
|
14
|
+
"""Monitor conflicts — live incident maps, event data, conflict timelines."""
|
|
15
|
+
|
|
16
|
+
category = FindingSource.CONFLICT
|
|
17
|
+
name = "conflict-monitor"
|
|
18
|
+
description = "Live conflict maps, incident data (ACLED), event timelines, situation reports"
|
|
19
|
+
free_tier_available = True
|
|
20
|
+
rate_limit_rps = 0.5
|
|
21
|
+
|
|
22
|
+
LIVEUAMAP_API = "https://liveuamap.com/"
|
|
23
|
+
ACLED_DASHBOARD = "https://acleddata.com/dashboard/"
|
|
24
|
+
|
|
25
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
26
|
+
findings: list[Finding] = []
|
|
27
|
+
|
|
28
|
+
# Extract location from query
|
|
29
|
+
location = self._extract_location(query)
|
|
30
|
+
if not location:
|
|
31
|
+
location = query[:60]
|
|
32
|
+
|
|
33
|
+
# 1. Live Universal Awareness Map
|
|
34
|
+
findings.append(
|
|
35
|
+
self._make_finding(
|
|
36
|
+
title=f"🗺 Live conflict map: {location}",
|
|
37
|
+
description=(
|
|
38
|
+
f"[LiveUAMap](https://liveuamap.com/) provides real-time conflict monitoring "
|
|
39
|
+
f"with geolocated events. Search for '{location}' on the map to see recent "
|
|
40
|
+
f"incidents, frontlines, and event timelines."
|
|
41
|
+
),
|
|
42
|
+
evidence=[
|
|
43
|
+
f"https://liveuamap.com/?q={location}",
|
|
44
|
+
"https://liveuamap.com/",
|
|
45
|
+
],
|
|
46
|
+
confidence=0.8,
|
|
47
|
+
location=location,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 2. ACLED data reference
|
|
52
|
+
findings.append(
|
|
53
|
+
self._make_finding(
|
|
54
|
+
title=f"📊 ACLED conflict data: {location}",
|
|
55
|
+
description=(
|
|
56
|
+
f"The Armed Conflict Location & Event Data Project (ACLED) tracks "
|
|
57
|
+
f"political violence and protest events worldwide. "
|
|
58
|
+
f"Use their dashboard to filter by location, date, and event type."
|
|
59
|
+
),
|
|
60
|
+
evidence=[
|
|
61
|
+
f"https://acleddata.com/dashboard/#/dashboard",
|
|
62
|
+
"https://acleddata.com/data-export-tool/",
|
|
63
|
+
],
|
|
64
|
+
confidence=0.85,
|
|
65
|
+
location=location,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# 3. Additional resources
|
|
70
|
+
findings.append(
|
|
71
|
+
self._make_finding(
|
|
72
|
+
title=f"📡 OSINT conflict resources for {location}",
|
|
73
|
+
description=(
|
|
74
|
+
"Recommended monitoring resources:\n"
|
|
75
|
+
f"- [LiveUAMap](https://liveuamap.com/?q={location}) — real-time mapped events\n"
|
|
76
|
+
f"- [ACLED Dashboard](https://acleddata.com/dashboard/) — structured event data\n"
|
|
77
|
+
f"- [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov/map/) — satellite fire/thermal detection\n"
|
|
78
|
+
f"- [Flightradar24](https://www.flightradar24.com/) — aircraft tracking near conflict zones\n"
|
|
79
|
+
f"- [MarineTraffic](https://www.marinetraffic.com/) — vessel movements"
|
|
80
|
+
),
|
|
81
|
+
confidence=0.7,
|
|
82
|
+
location=location,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return findings
|
|
87
|
+
|
|
88
|
+
def _extract_location(self, text: str) -> str | None:
|
|
89
|
+
"""Extract a location from conflict-related query."""
|
|
90
|
+
import re
|
|
91
|
+
|
|
92
|
+
patterns = [
|
|
93
|
+
r"(?:in|at|near|around)\s+([A-Z][a-zA-Z\s,]+?)(?:\s+(?:and|or|\.|$))",
|
|
94
|
+
r"(?:conflict|war|fighting|clashes|attacks?|incidents?)\s+(?:in|at)\s+([A-Z][a-zA-Z\s,]+?)(?:\s+(?:and|or|\.|$))",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
for pattern in patterns:
|
|
98
|
+
match = re.search(pattern, text, re.IGNORECASE)
|
|
99
|
+
if match:
|
|
100
|
+
return match.group(1).strip().rstrip(".,")
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Register
|
|
106
|
+
conflict_tool = ConflictTool()
|
|
107
|
+
registry.register(conflict_tool)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Corporate & Finance tool — company records, sanctions lists, SEC filings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.parse
|
|
6
|
+
|
|
7
|
+
from .base import OSINTTool
|
|
8
|
+
from .registry import registry
|
|
9
|
+
from ..core.models import Finding, FindingSeverity, FindingSource
|
|
10
|
+
from ..utils.http import get_client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CorporateTool(OSINTTool):
|
|
14
|
+
"""Investigate companies — OpenCorporates, OpenSanctions, SEC EDGAR."""
|
|
15
|
+
|
|
16
|
+
category = FindingSource.CORPORATE
|
|
17
|
+
name = "corporate-finance"
|
|
18
|
+
description = "Company registry lookup (OpenCorporates), sanctions check (OpenSanctions), SEC EDGAR"
|
|
19
|
+
free_tier_available = True
|
|
20
|
+
rate_limit_rps = 2.0
|
|
21
|
+
|
|
22
|
+
OPENCORPORATES_API = "https://api.opencorporates.com/v0.4/companies/search"
|
|
23
|
+
OPENSANCTIONS_API = "https://api.opensanctions.org/search/default"
|
|
24
|
+
SEC_EDGAR_SEARCH = "https://efts.sec.gov/LATEST/search-index?q={query}&pageSize=5"
|
|
25
|
+
|
|
26
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
27
|
+
findings: list[Finding] = []
|
|
28
|
+
|
|
29
|
+
company = self._extract_company_name(query)
|
|
30
|
+
if not company:
|
|
31
|
+
# Also check for person names (sanctions)
|
|
32
|
+
person = self._extract_person_name(query)
|
|
33
|
+
if person:
|
|
34
|
+
findings.extend(await self._check_sanctions(person))
|
|
35
|
+
return findings
|
|
36
|
+
|
|
37
|
+
# Fallback: extract any capitalized name for sanctions/company check
|
|
38
|
+
entity = self._extract_entity(query)
|
|
39
|
+
if entity:
|
|
40
|
+
findings.extend(await self._check_sanctions(entity))
|
|
41
|
+
findings.append(self._make_finding(
|
|
42
|
+
title=f"🔍 Entity search: {entity}",
|
|
43
|
+
description=(
|
|
44
|
+
f"Searching corporate records and sanctions for **{entity}**:\n"
|
|
45
|
+
f"- [OpenCorporates](https://opencorporates.com/companies?q={urllib.parse.quote(entity)})\n"
|
|
46
|
+
f"- [OpenSanctions](https://opensanctions.org/search/?q={urllib.parse.quote(entity)})\n"
|
|
47
|
+
f"- [Google](https://www.google.com/search?q={urllib.parse.quote(entity)}+company+sanctions)"
|
|
48
|
+
),
|
|
49
|
+
evidence=[
|
|
50
|
+
f"https://opencorporates.com/companies?q={urllib.parse.quote(entity)}",
|
|
51
|
+
f"https://opensanctions.org/search/?q={urllib.parse.quote(entity)}",
|
|
52
|
+
],
|
|
53
|
+
confidence=0.5,
|
|
54
|
+
))
|
|
55
|
+
return findings
|
|
56
|
+
return findings
|
|
57
|
+
|
|
58
|
+
# 1. OpenCorporates search
|
|
59
|
+
oc_result = await self._search_opencorporates(company)
|
|
60
|
+
if oc_result:
|
|
61
|
+
findings.append(oc_result)
|
|
62
|
+
|
|
63
|
+
# 2. Sanctions check
|
|
64
|
+
sanctions = await self._check_sanctions(company)
|
|
65
|
+
findings.extend(sanctions)
|
|
66
|
+
|
|
67
|
+
# 3. SEC EDGAR (for US companies)
|
|
68
|
+
edgar = await self._search_edgar(company)
|
|
69
|
+
if edgar:
|
|
70
|
+
findings.append(edgar)
|
|
71
|
+
|
|
72
|
+
return findings
|
|
73
|
+
|
|
74
|
+
async def _search_opencorporates(self, company: str) -> Finding | None:
|
|
75
|
+
"""Search OpenCorporates for company records. Uses API key if configured."""
|
|
76
|
+
import httpx
|
|
77
|
+
try:
|
|
78
|
+
from watson.api_keys import get_key
|
|
79
|
+
api_token = get_key("opencorporates")
|
|
80
|
+
|
|
81
|
+
params: dict = {"q": company, "per_page": 5}
|
|
82
|
+
headers = {"User-Agent": "WatsonOSINT/0.3"}
|
|
83
|
+
if api_token:
|
|
84
|
+
headers["Authorization"] = f"Token {api_token}"
|
|
85
|
+
|
|
86
|
+
async with httpx.AsyncClient(timeout=10) as c:
|
|
87
|
+
resp = await c.get(self.OPENCORPORATES_API, params=params, headers=headers)
|
|
88
|
+
if resp.status_code != 200:
|
|
89
|
+
return None
|
|
90
|
+
data = resp.json()
|
|
91
|
+
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
return None
|
|
94
|
+
results = data.get("results", {}).get("companies", [])
|
|
95
|
+
if results:
|
|
96
|
+
companies = []
|
|
97
|
+
for r in results[:5]:
|
|
98
|
+
c = r.get("company", {})
|
|
99
|
+
name = c.get("name", "Unknown")
|
|
100
|
+
jurisdiction = c.get("jurisdiction_code", "??")
|
|
101
|
+
company_number = c.get("company_number", "")
|
|
102
|
+
companies.append(f"- **{name}** ({jurisdiction}, #{company_number})")
|
|
103
|
+
|
|
104
|
+
return self._make_finding(
|
|
105
|
+
title=f"🏢 Company records: {len(results)} matches for '{company}'",
|
|
106
|
+
description="\n".join(companies),
|
|
107
|
+
evidence=[f"https://opencorporates.com/companies?q={urllib.parse.quote(company)}"],
|
|
108
|
+
confidence=0.85,
|
|
109
|
+
query=company,
|
|
110
|
+
result_count=len(results),
|
|
111
|
+
)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return self._make_finding(
|
|
114
|
+
title=f"⚠️ OpenCorporates lookup failed for '{company}'",
|
|
115
|
+
description=f"API error: {str(e)[:200]}. Try manually: https://opencorporates.com/companies?q={urllib.parse.quote(company)}",
|
|
116
|
+
evidence=[f"https://opencorporates.com/companies?q={urllib.parse.quote(company)}"],
|
|
117
|
+
confidence=0.1,
|
|
118
|
+
severity=FindingSeverity.LOW,
|
|
119
|
+
query=company,
|
|
120
|
+
)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
async def _check_sanctions(self, name: str) -> list[Finding]:
|
|
124
|
+
"""Check OpenSanctions for sanctions/restrictions via authenticated API.
|
|
125
|
+
|
|
126
|
+
Filters results: only shows entities whose name/aliases actually contain
|
|
127
|
+
the search term. OpenSanctions search is fuzzy — "Aviloop" returns
|
|
128
|
+
unrelated Georgian LLCs, Russian oligarchs, etc. without filtering.
|
|
129
|
+
"""
|
|
130
|
+
findings: list[Finding] = []
|
|
131
|
+
import httpx
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
import os
|
|
135
|
+
api_key = os.environ.get("OPENSANCTIONS_API_KEY", "")
|
|
136
|
+
params = {"q": name, "limit": 10}
|
|
137
|
+
headers = {"User-Agent": "WatsonOSINT/0.3"}
|
|
138
|
+
if api_key:
|
|
139
|
+
headers["Authorization"] = f"ApiKey {api_key}"
|
|
140
|
+
|
|
141
|
+
async with httpx.AsyncClient(timeout=10) as raw:
|
|
142
|
+
resp = await raw.get(self.OPENSANCTIONS_API, params=params, headers=headers)
|
|
143
|
+
if resp.status_code != 200:
|
|
144
|
+
return findings
|
|
145
|
+
data = resp.json()
|
|
146
|
+
|
|
147
|
+
results = data.get("results", [])
|
|
148
|
+
# Relevance filter: name must appear in caption/aliases (case-insensitive)
|
|
149
|
+
query_lower = name.lower()
|
|
150
|
+
relevant = []
|
|
151
|
+
for r in results:
|
|
152
|
+
caption = (r.get("caption") or r.get("name") or "").lower()
|
|
153
|
+
aliases = " ".join(r.get("aliases", [])).lower()
|
|
154
|
+
if query_lower in caption or query_lower in aliases:
|
|
155
|
+
relevant.append(r)
|
|
156
|
+
|
|
157
|
+
if relevant:
|
|
158
|
+
sanctioned = []
|
|
159
|
+
for r in relevant[:5]:
|
|
160
|
+
r_name = r.get("caption", r.get("name", "Unknown"))
|
|
161
|
+
schema = r.get("schema", "")
|
|
162
|
+
countries = ", ".join(r.get("countries", []))
|
|
163
|
+
datasets = r.get("datasets", [])
|
|
164
|
+
sanction_lists = ", ".join(datasets) if datasets else "N/A"
|
|
165
|
+
topics = r.get("topics", [])
|
|
166
|
+
topic_str = ", ".join(topics) if topics else ""
|
|
167
|
+
|
|
168
|
+
sanctioned.append(
|
|
169
|
+
f"- **{r_name}** [{schema}]\n"
|
|
170
|
+
f" Countries: {countries}\n"
|
|
171
|
+
f" Sanction lists: {sanction_lists}\n"
|
|
172
|
+
f" Topics: {topic_str}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
findings.append(
|
|
176
|
+
self._make_finding(
|
|
177
|
+
title=f"🚨 SANCTIONS MATCH: {len(relevant)} entries for '{name}'",
|
|
178
|
+
description="\n".join(sanctioned),
|
|
179
|
+
evidence=[f"https://opensanctions.org/search/?q={urllib.parse.quote(name)}"],
|
|
180
|
+
confidence=0.95,
|
|
181
|
+
query=name,
|
|
182
|
+
result_count=len(relevant),
|
|
183
|
+
sanction_match=True,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
findings.append(
|
|
188
|
+
self._make_finding(
|
|
189
|
+
title=f"✅ No sanctions found: '{name}'",
|
|
190
|
+
description=f"No relevant sanctions matches in OpenSanctions ({len(results)} raw results filtered — none contained the target name).",
|
|
191
|
+
confidence=0.85,
|
|
192
|
+
query=name,
|
|
193
|
+
sanction_match=False,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
return findings
|
|
200
|
+
|
|
201
|
+
async def _search_edgar(self, company: str) -> Finding | None:
|
|
202
|
+
"""Search SEC EDGAR for US company filings."""
|
|
203
|
+
try:
|
|
204
|
+
return self._make_finding(
|
|
205
|
+
title=f"📊 SEC EDGAR search ready: {company}",
|
|
206
|
+
description="Click to search SEC filings for this company.",
|
|
207
|
+
evidence=[
|
|
208
|
+
f"https://www.sec.gov/cgi-bin/browse-edgar?company={urllib.parse.quote(company)}"
|
|
209
|
+
],
|
|
210
|
+
confidence=0.7,
|
|
211
|
+
query=company,
|
|
212
|
+
)
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
def _extract_company_name(self, text: str) -> str | None:
|
|
218
|
+
"""Extract a company name from query text."""
|
|
219
|
+
import re
|
|
220
|
+
|
|
221
|
+
patterns = [
|
|
222
|
+
r"(?:company|corporation|business|firm|entity)\s+(?:named|called|is\s+)?['\"]?([A-Z][A-Za-z0-9\s&.,]+?)(?:\s+(?:and|or|\.|$|,))",
|
|
223
|
+
r"(?:investigate|research|look\s+up|check|audit)\s+(?:the\s+)?(?:company\s+)?['\"]?([A-Z][A-Za-z0-9\s&.,]+?)(?:\s+(?:and|or|\.|$))",
|
|
224
|
+
r"(?:who owns|who controls|ownership of)\s+['\"]?([A-Z][A-Za-z0-9\s&.,]+?)(?:\s+(?:and|or|\?|\.|$))",
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
for pattern in patterns:
|
|
228
|
+
match = re.search(pattern, text, re.IGNORECASE)
|
|
229
|
+
if match:
|
|
230
|
+
name = match.group(1).strip().rstrip(".,")
|
|
231
|
+
# Filter out common non-company words
|
|
232
|
+
if len(name.split()) >= 1 and name.lower() not in (
|
|
233
|
+
"this", "that", "the", "a", "an"
|
|
234
|
+
):
|
|
235
|
+
return name
|
|
236
|
+
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
def _extract_person_name(self, text: str) -> str | None:
|
|
240
|
+
"""Extract a person's name from query text."""
|
|
241
|
+
import re
|
|
242
|
+
|
|
243
|
+
patterns = [
|
|
244
|
+
r"(?:person|individual|someone|called|named)\s+['\"]?([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})['\"]?",
|
|
245
|
+
r"(?:who is|research|look\s+up|check|investigate)\s+['\"]?([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})['\"]?",
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
for pattern in patterns:
|
|
249
|
+
match = re.search(pattern, text, re.IGNORECASE)
|
|
250
|
+
if match:
|
|
251
|
+
return match.group(1).strip()
|
|
252
|
+
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
def _extract_entity(self, text: str) -> str | None:
|
|
256
|
+
"""Extract any capitalized entity name (person or company) from unstructured text."""
|
|
257
|
+
import re
|
|
258
|
+
|
|
259
|
+
# Strip common investigative keywords
|
|
260
|
+
stripped = re.sub(
|
|
261
|
+
r'\b(?:company|companies|sanctions?|corporate|investigate|research|look\s+up|check|search|find)\b',
|
|
262
|
+
'', text, flags=re.IGNORECASE
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Strip quotes
|
|
266
|
+
stripped = re.sub(r'["\']', '', stripped)
|
|
267
|
+
|
|
268
|
+
# Find capitalized name sequences (1-3 words)
|
|
269
|
+
match = re.search(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b', stripped)
|
|
270
|
+
if match:
|
|
271
|
+
name = match.group(1).strip()
|
|
272
|
+
if len(name) > 2:
|
|
273
|
+
return name
|
|
274
|
+
|
|
275
|
+
# Fallback: any alphanumeric entity (e.g. "OpenAI", "DeepSeek", "Company123")
|
|
276
|
+
match = re.search(r'\b([A-Za-z][A-Za-z0-9]{2,}(?:\s+[A-Za-z][A-Za-z0-9]{1,}){0,2})\b', stripped)
|
|
277
|
+
if match:
|
|
278
|
+
name = match.group(1).strip()
|
|
279
|
+
if name.lower() not in ("who", "what", "where", "when", "why", "how", "the", "and", "for", "with"):
|
|
280
|
+
return name
|
|
281
|
+
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Register
|
|
286
|
+
corporate_tool = CorporateTool()
|
|
287
|
+
registry.register(corporate_tool)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Dark-web intelligence — ransomware, pastebin, breach lookups.
|
|
2
|
+
|
|
3
|
+
Implements the darkweb_tool expected by watson.tools_darkweb.
|
|
4
|
+
Uses clearnet indexes (ransomware.live, RansomWatch) — no Tor required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("watson.darkweb")
|
|
14
|
+
|
|
15
|
+
# ── Data structures ─────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DarkWebFinding:
|
|
19
|
+
title: str
|
|
20
|
+
description: str
|
|
21
|
+
source_url: str = ""
|
|
22
|
+
source_type: str = "darkweb"
|
|
23
|
+
confidence: float = 0.5
|
|
24
|
+
evidence: list[str] = None
|
|
25
|
+
|
|
26
|
+
def __post_init__(self):
|
|
27
|
+
if self.evidence is None:
|
|
28
|
+
self.evidence = []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DarkWebTool:
|
|
32
|
+
"""Searches ransomware databases, pastebins, and breach indexes."""
|
|
33
|
+
|
|
34
|
+
name = "darkweb"
|
|
35
|
+
|
|
36
|
+
async def investigate(self, query: str, target_type: str = "topic") -> list[DarkWebFinding]:
|
|
37
|
+
"""Run dark-web investigation. Returns DarkWebFinding objects."""
|
|
38
|
+
findings: list[DarkWebFinding] = []
|
|
39
|
+
|
|
40
|
+
# Run all searches concurrently
|
|
41
|
+
tasks = [
|
|
42
|
+
self._ransomware_live(query),
|
|
43
|
+
self._ransomwatch(query),
|
|
44
|
+
]
|
|
45
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
46
|
+
|
|
47
|
+
for result in results:
|
|
48
|
+
if isinstance(result, Exception):
|
|
49
|
+
logger.warning("darkweb search failed: %s", result)
|
|
50
|
+
elif result:
|
|
51
|
+
findings.extend(result)
|
|
52
|
+
|
|
53
|
+
if not findings:
|
|
54
|
+
findings.append(DarkWebFinding(
|
|
55
|
+
title=f"No dark-web results for \"{query}\"",
|
|
56
|
+
description="No ransomware groups, victims, or pastebin mentions found.",
|
|
57
|
+
confidence=0.2,
|
|
58
|
+
))
|
|
59
|
+
|
|
60
|
+
return findings
|
|
61
|
+
|
|
62
|
+
async def _ransomware_live(self, query: str) -> list[DarkWebFinding]:
|
|
63
|
+
"""Query ransomware.live for group profiles."""
|
|
64
|
+
try:
|
|
65
|
+
import aiohttp
|
|
66
|
+
except ImportError:
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
async with aiohttp.ClientSession() as session:
|
|
71
|
+
async with session.get(
|
|
72
|
+
"https://api.ransomware.live/v2/groups",
|
|
73
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
74
|
+
) as resp:
|
|
75
|
+
if resp.status != 200:
|
|
76
|
+
return []
|
|
77
|
+
groups = await resp.json()
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.debug("ransomware.live unavailable: %s", e)
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
findings = []
|
|
83
|
+
query_lower = query.lower()
|
|
84
|
+
|
|
85
|
+
for group in (groups or []):
|
|
86
|
+
name = (group.get("group_name", "") or "").lower()
|
|
87
|
+
if query_lower in name:
|
|
88
|
+
findings.append(DarkWebFinding(
|
|
89
|
+
title=f"🕶️ Ransomware group: \"{group.get('group_name', '?')}\"",
|
|
90
|
+
description=(
|
|
91
|
+
f" • **{group.get('group_name', '?')}** "
|
|
92
|
+
f"— {group.get('country', 'Unknown')} "
|
|
93
|
+
f"— {group.get('status', 'Unknown')}"
|
|
94
|
+
),
|
|
95
|
+
source_url="https://www.ransomware.live",
|
|
96
|
+
confidence=0.85,
|
|
97
|
+
evidence=[f"https://api.ransomware.live/v2/groups"],
|
|
98
|
+
))
|
|
99
|
+
|
|
100
|
+
return findings[:5]
|
|
101
|
+
|
|
102
|
+
async def _ransomwatch(self, query: str) -> list[DarkWebFinding]:
|
|
103
|
+
"""Query RansomWatch for victim posts."""
|
|
104
|
+
try:
|
|
105
|
+
import aiohttp
|
|
106
|
+
except ImportError:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
async with aiohttp.ClientSession() as session:
|
|
111
|
+
async with session.get(
|
|
112
|
+
"https://raw.githubusercontent.com/joshhighet/ransomwatch/main/posts.json",
|
|
113
|
+
timeout=aiohttp.ClientTimeout(total=20),
|
|
114
|
+
) as resp:
|
|
115
|
+
if resp.status != 200:
|
|
116
|
+
return []
|
|
117
|
+
data = await resp.json()
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.debug("RansomWatch unavailable: %s", e)
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
findings = []
|
|
123
|
+
query_lower = query.lower()
|
|
124
|
+
|
|
125
|
+
for post in (data or [])[:200]: # Limit to recent 200
|
|
126
|
+
group = (post.get("group_name", "") or "").lower()
|
|
127
|
+
title = (post.get("post_title", "") or "").lower()
|
|
128
|
+
if query_lower in group or query_lower in title:
|
|
129
|
+
findings.append(DarkWebFinding(
|
|
130
|
+
title=f"RansomWatch: {post.get('group_name', '?')} — {post.get('post_title', '?')[:80]}",
|
|
131
|
+
description=(
|
|
132
|
+
f"Group: {post.get('group_name', '?')} | "
|
|
133
|
+
f"Date: {post.get('discovered', '?')[:10]}"
|
|
134
|
+
),
|
|
135
|
+
source_url=post.get("url", "https://ransomwatch.telemetry.ltd"),
|
|
136
|
+
confidence=0.70,
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
return findings[:10]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Singleton instance ──────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
darkweb_tool = DarkWebTool()
|