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,359 @@
|
|
|
1
|
+
"""Blockchain investigation tool — Ethereum + Bitcoin, no API key needed.
|
|
2
|
+
|
|
3
|
+
Uses Blockscout (open-source Etherscan alternative) for Ethereum and
|
|
4
|
+
Blockchain.info for Bitcoin. Both are free with generous rate limits.
|
|
5
|
+
|
|
6
|
+
Capabilities:
|
|
7
|
+
- Wallet balance lookup (ETH/BTC)
|
|
8
|
+
- Transaction history (recent 50)
|
|
9
|
+
- ENS domain resolution
|
|
10
|
+
- Token holdings (ERC-20)
|
|
11
|
+
- Exchange attribution (known exchange addresses)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from .base import OSINTTool
|
|
22
|
+
from .registry import registry
|
|
23
|
+
from ..core.models import Finding, FindingSeverity, FindingSource
|
|
24
|
+
from ..utils.http import get_client
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("watson.crypto")
|
|
27
|
+
|
|
28
|
+
# Known exchange addresses (for attribution)
|
|
29
|
+
KNOWN_EXCHANGES = {
|
|
30
|
+
"0x28C6c06298d514Db089934071355E5743bf21d60": "Binance",
|
|
31
|
+
"0x3f5CE5FBFe3E9af3971dD833D26bA9b5C936f0bE": "Binance",
|
|
32
|
+
"0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8": "Binance",
|
|
33
|
+
"0xF977814e90dA44bFA03b6295A0616a89744199C5": "Binance",
|
|
34
|
+
"0x5E3346444010135322268a4630d2ed5F8D09446C": "Binance",
|
|
35
|
+
"0xA7EFAe728D2936e78BDA97dc267687568dD593f3": "Binance",
|
|
36
|
+
"0xDFd5293D8e347dFe59E90eFd55429C5D26259a91": "Binance",
|
|
37
|
+
"0x56Eddb7aa87536c09CCc2793473599fD21A8b17F": "Binance",
|
|
38
|
+
"0xD688AEA8f7dC9088D009e8eFdCE6d0877fC8DF4c": "Coinbase",
|
|
39
|
+
"0x71660c4005BA85c37c855d479861432c0Fb787b17": "Coinbase",
|
|
40
|
+
"0x503828976D22510aad0201ac7EC2421ae51c927A": "Coinbase",
|
|
41
|
+
"0xddfAbCdc4D8FfC6d5beaf154f18B778f892A074a": "Kraken",
|
|
42
|
+
"0x267be1C1D684F78cb4F6c176783921476d2b7da9": "Kraken",
|
|
43
|
+
"0x22c264eD08d9E47A28E380F3a93e5990cC42f4F0": "FTX",
|
|
44
|
+
"0xC098B2a3Aa256D2140208C3de674f736a6746528": "Bitfinex",
|
|
45
|
+
"0x1151314c646Ce4E0eFD76d1aF4760aE66a9Fe30F": "Bitfinex",
|
|
46
|
+
"0x36A85757645E8e8aeA06c2D52A2449c4b226BeC4": "KuCoin",
|
|
47
|
+
"0x689c56AEf474Df92D44A1B70850f808488F9769C": "KuCoin",
|
|
48
|
+
"0x8894E0a0c962CB723c1976a4421c95949bE2D4E3": "Interpol Seized",
|
|
49
|
+
"0x8576aCC5C05D6Ce88f4e49bf65BdF0C62F91353C": "DEA Seized",
|
|
50
|
+
"0x9F4cda45B4D3f1f6cB4aB9D5dA2A6c6c4b9c5b3a": "USDT Tether Treasury",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# ERC-20 token contracts to check
|
|
54
|
+
TOKEN_CONTRACTS = {
|
|
55
|
+
"0xdAC17F958D2ee523a2206206994597C13D831ec7": "USDT",
|
|
56
|
+
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": "USDC",
|
|
57
|
+
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "WETH",
|
|
58
|
+
"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599": "WBTC",
|
|
59
|
+
"0x514910771AF9Ca656af840dff83E8264EcF986CA": "LINK",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BlockchainTool(OSINTTool):
|
|
64
|
+
"""Investigate cryptocurrency wallets — Ethereum + Bitcoin, no API key needed."""
|
|
65
|
+
|
|
66
|
+
category = FindingSource.CORPORATE
|
|
67
|
+
name = "blockchain"
|
|
68
|
+
description = "Ethereum (Blockscout) + Bitcoin (Blockchain.info) wallet investigation — balance, TXs, ENS, tokens"
|
|
69
|
+
free_tier_available = True
|
|
70
|
+
rate_limit_rps = 2.0
|
|
71
|
+
|
|
72
|
+
BLOCKSCOUT_API = "https://eth.blockscout.com/api/v2"
|
|
73
|
+
BLOCKCHAIN_INFO = "https://blockchain.info/rawaddr"
|
|
74
|
+
|
|
75
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
76
|
+
findings: list[Finding] = []
|
|
77
|
+
client = get_client(rate_limit=self.rate_limit_rps)
|
|
78
|
+
|
|
79
|
+
# Extract wallet address from query
|
|
80
|
+
address = self._extract_address(query)
|
|
81
|
+
if not address:
|
|
82
|
+
return findings
|
|
83
|
+
|
|
84
|
+
# Route by chain
|
|
85
|
+
if address.startswith("0x") and len(address) == 42:
|
|
86
|
+
# Ethereum
|
|
87
|
+
findings.extend(await self._investigate_ethereum(client, address))
|
|
88
|
+
elif len(address) in (34, 42) and address[0] in ("1", "3", "b", "B"):
|
|
89
|
+
# Bitcoin
|
|
90
|
+
findings.extend(await self._investigate_bitcoin(client, address))
|
|
91
|
+
else:
|
|
92
|
+
# Unknown format — try Ethereum first
|
|
93
|
+
findings.extend(await self._investigate_ethereum(client, address))
|
|
94
|
+
|
|
95
|
+
return findings
|
|
96
|
+
|
|
97
|
+
def _extract_address(self, query: str) -> str | None:
|
|
98
|
+
"""Extract a crypto wallet address from query text."""
|
|
99
|
+
# Ethereum (0x + 40 hex chars)
|
|
100
|
+
match = re.search(r'(0x[a-fA-F0-9]{40})', query)
|
|
101
|
+
if match:
|
|
102
|
+
return match.group(1)
|
|
103
|
+
|
|
104
|
+
# Bitcoin (legacy P2PKH: 1..., P2SH: 3..., Bech32: bc1...)
|
|
105
|
+
match = re.search(r'(\b(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39,59})\b)', query)
|
|
106
|
+
if match:
|
|
107
|
+
return match.group(1)
|
|
108
|
+
|
|
109
|
+
# If the entire query looks like an address
|
|
110
|
+
query = query.strip()
|
|
111
|
+
if re.match(r'^0x[a-fA-F0-9]{40}$', query):
|
|
112
|
+
return query
|
|
113
|
+
if re.match(r'^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$', query):
|
|
114
|
+
return query
|
|
115
|
+
if re.match(r'^bc1[a-z0-9]{39,59}$', query, re.I):
|
|
116
|
+
return query
|
|
117
|
+
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# ── Ethereum ──────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
async def _investigate_ethereum(self, client, address: str) -> list[Finding]:
|
|
123
|
+
findings: list[Finding] = []
|
|
124
|
+
addr_lower = address.lower()
|
|
125
|
+
|
|
126
|
+
# 1. Address info (balance, ENS, TX count)
|
|
127
|
+
try:
|
|
128
|
+
data = await client.get_json(f"{self.BLOCKSCOUT_API}/addresses/{address}")
|
|
129
|
+
balance_wei = int(data.get("coin_balance", "0"))
|
|
130
|
+
balance_eth = balance_wei / 1e18
|
|
131
|
+
tx_count = data.get("transactions_count", 0)
|
|
132
|
+
ens = data.get("ens_domain_name")
|
|
133
|
+
is_contract = data.get("is_contract", False)
|
|
134
|
+
|
|
135
|
+
desc_parts = [
|
|
136
|
+
f"**Balance:** {balance_eth:.4f} ETH",
|
|
137
|
+
f"**Total transactions:** {tx_count:,}",
|
|
138
|
+
]
|
|
139
|
+
if ens:
|
|
140
|
+
desc_parts.append(f"**ENS domain:** {ens}")
|
|
141
|
+
if is_contract:
|
|
142
|
+
desc_parts.append("**Type:** Smart contract")
|
|
143
|
+
else:
|
|
144
|
+
desc_parts.append("**Type:** Externally owned account (EOA)")
|
|
145
|
+
|
|
146
|
+
# Check if known exchange
|
|
147
|
+
exchange = KNOWN_EXCHANGES.get(address)
|
|
148
|
+
if exchange:
|
|
149
|
+
desc_parts.append(f"**⚠️ Known exchange:** {exchange}")
|
|
150
|
+
|
|
151
|
+
# Token balances
|
|
152
|
+
token_balances = await self._get_token_balances(client, address)
|
|
153
|
+
if token_balances:
|
|
154
|
+
desc_parts.append("\n**Token holdings:**")
|
|
155
|
+
for symbol, balance in token_balances.items():
|
|
156
|
+
desc_parts.append(f" - {balance} {symbol}")
|
|
157
|
+
|
|
158
|
+
findings.append(self._make_finding(
|
|
159
|
+
title=f"💎 Ethereum Wallet: {balance_eth:.2f} ETH — {address[:12]}...",
|
|
160
|
+
description="\n".join(desc_parts),
|
|
161
|
+
evidence=[f"https://eth.blockscout.com/address/{address}"],
|
|
162
|
+
confidence=0.95,
|
|
163
|
+
severity=FindingSeverity.HIGH if balance_eth > 1 else FindingSeverity.INFO,
|
|
164
|
+
address=address,
|
|
165
|
+
balance_eth=balance_eth,
|
|
166
|
+
tx_count=tx_count,
|
|
167
|
+
ens_domain=ens,
|
|
168
|
+
is_exchange=bool(exchange),
|
|
169
|
+
exchange_name=exchange,
|
|
170
|
+
))
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.warning("blockscout_address_failed: %s", e)
|
|
173
|
+
findings.append(self._make_finding(
|
|
174
|
+
title=f"⚠️ Blockscout lookup failed for {address[:12]}...",
|
|
175
|
+
description=f"Could not retrieve address data: {str(e)[:200]}. View manually: https://etherscan.io/address/{address}",
|
|
176
|
+
evidence=[f"https://etherscan.io/address/{address}"],
|
|
177
|
+
confidence=0.2,
|
|
178
|
+
severity=FindingSeverity.LOW,
|
|
179
|
+
))
|
|
180
|
+
|
|
181
|
+
# 2. Recent transactions + counterparty analysis
|
|
182
|
+
try:
|
|
183
|
+
tx_data = await client.get_json(
|
|
184
|
+
f"{self.BLOCKSCOUT_API}/addresses/{address}/transactions",
|
|
185
|
+
params={"filter": "from"},
|
|
186
|
+
)
|
|
187
|
+
txs = tx_data.get("items", [])
|
|
188
|
+
|
|
189
|
+
if txs:
|
|
190
|
+
tx_lines = []
|
|
191
|
+
counterparties = {} # addr -> count
|
|
192
|
+
contracts_found = set()
|
|
193
|
+
exchanges_found = set()
|
|
194
|
+
|
|
195
|
+
for tx in txs[:10]:
|
|
196
|
+
value_eth = int(tx.get("value", "0")) / 1e18
|
|
197
|
+
to_addr = tx.get("to", {}).get("hash", "?")
|
|
198
|
+
to_short = to_addr[:14]
|
|
199
|
+
tx_hash = tx.get("hash", "?")[:20]
|
|
200
|
+
timestamp = tx.get("timestamp", "?")[:10]
|
|
201
|
+
|
|
202
|
+
# Track counterparties for analysis
|
|
203
|
+
counterparties[to_addr] = counterparties.get(to_addr, 0) + 1
|
|
204
|
+
|
|
205
|
+
# Check if destination is a known exchange
|
|
206
|
+
to_full = tx.get("to", {}).get("hash", "")
|
|
207
|
+
ex = KNOWN_EXCHANGES.get(to_full, KNOWN_EXCHANGES.get(to_full.lower(), ""))
|
|
208
|
+
if ex:
|
|
209
|
+
exchanges_found.add(ex)
|
|
210
|
+
|
|
211
|
+
# Identify token contracts
|
|
212
|
+
contract_id = TOKEN_CONTRACTS.get(to_full)
|
|
213
|
+
if contract_id:
|
|
214
|
+
contracts_found.add(contract_id)
|
|
215
|
+
|
|
216
|
+
# Build marker — prioritize contract ID over exchange
|
|
217
|
+
if contract_id:
|
|
218
|
+
marker = f" [ERC-20: {contract_id}]"
|
|
219
|
+
elif ex:
|
|
220
|
+
marker = f" → {ex}"
|
|
221
|
+
else:
|
|
222
|
+
marker = ""
|
|
223
|
+
|
|
224
|
+
tx_lines.append(
|
|
225
|
+
f" - [{timestamp}] {value_eth:.4f} ETH → {to_short}...{marker}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
findings.append(self._make_finding(
|
|
229
|
+
title=f"💸 Recent ETH transactions: {len(txs)} from {address[:12]}...",
|
|
230
|
+
description="\n".join(tx_lines),
|
|
231
|
+
evidence=[f"https://eth.blockscout.com/address/{address}/transactions"],
|
|
232
|
+
confidence=0.9,
|
|
233
|
+
severity=FindingSeverity.MEDIUM,
|
|
234
|
+
address=address,
|
|
235
|
+
tx_count=len(txs),
|
|
236
|
+
))
|
|
237
|
+
|
|
238
|
+
# Counterparty analysis: summary of who this wallet interacts with
|
|
239
|
+
unique_counterparties = len(counterparties)
|
|
240
|
+
summary_parts = []
|
|
241
|
+
if contracts_found:
|
|
242
|
+
summary_parts.append(f"**Token contracts:** {', '.join(sorted(contracts_found))} (0 ETH transfers are ERC-20 token interactions, not value transfers)")
|
|
243
|
+
if exchanges_found:
|
|
244
|
+
summary_parts.append(f"**Exchanges:** {', '.join(sorted(exchanges_found))}")
|
|
245
|
+
summary_parts.append(f"**Unique counterparties:** {unique_counterparties}")
|
|
246
|
+
if unique_counterparties <= 5:
|
|
247
|
+
# Show all counterparties for low-activity wallets
|
|
248
|
+
for addr, count in sorted(counterparties.items(), key=lambda x: -x[1]):
|
|
249
|
+
label = KNOWN_EXCHANGES.get(addr, TOKEN_CONTRACTS.get(addr, ""))
|
|
250
|
+
label_str = f" [{label}]" if label else ""
|
|
251
|
+
summary_parts.append(f" - {addr}{label_str} ({count} txn{'s' if count > 1 else ''})")
|
|
252
|
+
|
|
253
|
+
findings.append(self._make_finding(
|
|
254
|
+
title=f"🔍 Counterparty analysis: {unique_counterparties} unique wallets",
|
|
255
|
+
description="\n".join(summary_parts),
|
|
256
|
+
evidence=[f"https://eth.blockscout.com/address/{address}/transactions"],
|
|
257
|
+
confidence=0.85,
|
|
258
|
+
severity=FindingSeverity.MEDIUM,
|
|
259
|
+
address=address,
|
|
260
|
+
counterparty_count=unique_counterparties,
|
|
261
|
+
))
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logger.warning("blockscout_txs_failed: %s", e)
|
|
264
|
+
|
|
265
|
+
return findings
|
|
266
|
+
|
|
267
|
+
async def _get_token_balances(self, client, address: str) -> dict[str, float]:
|
|
268
|
+
"""Check ERC-20 token balances via Blockscout."""
|
|
269
|
+
balances: dict[str, float] = {}
|
|
270
|
+
try:
|
|
271
|
+
data = await client.get_json(
|
|
272
|
+
f"{self.BLOCKSCOUT_API}/addresses/{address}/token-balances"
|
|
273
|
+
)
|
|
274
|
+
for token in data if isinstance(data, list) else []:
|
|
275
|
+
symbol = token.get("token", {}).get("symbol", "?")
|
|
276
|
+
decimals = int(token.get("token", {}).get("decimals", 18))
|
|
277
|
+
raw_balance = int(token.get("value", "0"))
|
|
278
|
+
if raw_balance > 0:
|
|
279
|
+
balance = raw_balance / (10 ** decimals)
|
|
280
|
+
if balance > 0.01: # Skip dust
|
|
281
|
+
balances[symbol] = round(balance, 4)
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
return balances
|
|
285
|
+
|
|
286
|
+
# ── Bitcoin ──────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
async def _investigate_bitcoin(self, client, address: str) -> list[Finding]:
|
|
289
|
+
findings: list[Finding] = []
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
data = await client.get_json(f"{self.BLOCKCHAIN_INFO}/{address}?limit=10")
|
|
293
|
+
|
|
294
|
+
balance_btc = data.get("final_balance", 0) / 1e8
|
|
295
|
+
total_received = data.get("total_received", 0) / 1e8
|
|
296
|
+
total_sent = data.get("total_sent", 0) / 1e8
|
|
297
|
+
n_tx = data.get("n_tx", 0)
|
|
298
|
+
|
|
299
|
+
desc_parts = [
|
|
300
|
+
f"**Balance:** {balance_btc:.4f} BTC",
|
|
301
|
+
f"**Total received:** {total_received:.4f} BTC",
|
|
302
|
+
f"**Total sent:** {total_sent:.4f} BTC",
|
|
303
|
+
f"**Transaction count:** {n_tx:,}",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
findings.append(self._make_finding(
|
|
307
|
+
title=f"₿ Bitcoin Wallet: {balance_btc:.4f} BTC — {address[:12]}...",
|
|
308
|
+
description="\n".join(desc_parts),
|
|
309
|
+
evidence=[f"https://blockchain.info/address/{address}"],
|
|
310
|
+
confidence=0.95,
|
|
311
|
+
severity=FindingSeverity.HIGH if balance_btc > 0.1 else FindingSeverity.INFO,
|
|
312
|
+
address=address,
|
|
313
|
+
balance_btc=balance_btc,
|
|
314
|
+
tx_count=n_tx,
|
|
315
|
+
))
|
|
316
|
+
|
|
317
|
+
# Recent transactions
|
|
318
|
+
txs = data.get("txs", [])
|
|
319
|
+
if txs:
|
|
320
|
+
tx_lines = []
|
|
321
|
+
for tx in txs[:10]:
|
|
322
|
+
import time
|
|
323
|
+
ts = tx.get("time", 0)
|
|
324
|
+
date_str = time.strftime("%Y-%m-%d %H:%M", time.gmtime(ts)) if ts else "?"
|
|
325
|
+
|
|
326
|
+
# Calculate net amount
|
|
327
|
+
total_in = sum(o.get("value", 0) for o in tx.get("out", []) if o.get("addr") == address)
|
|
328
|
+
total_out = sum(i.get("value", 0) for i in tx.get("inputs", []) if i.get("prev_out", {}).get("addr") == address)
|
|
329
|
+
net = (total_in - total_out) / 1e8
|
|
330
|
+
|
|
331
|
+
tx_hash = tx.get("hash", "?")[:20]
|
|
332
|
+
direction = "received" if net > 0 else "sent"
|
|
333
|
+
tx_lines.append(f" - [{date_str}] {abs(net):.4f} BTC {direction} (TX: {tx_hash}...)")
|
|
334
|
+
|
|
335
|
+
findings.append(self._make_finding(
|
|
336
|
+
title=f"💸 Recent BTC transactions: {len(txs)} for {address[:12]}...",
|
|
337
|
+
description="\n".join(tx_lines),
|
|
338
|
+
evidence=[f"https://blockchain.info/address/{address}"],
|
|
339
|
+
confidence=0.9,
|
|
340
|
+
severity=FindingSeverity.MEDIUM,
|
|
341
|
+
address=address,
|
|
342
|
+
))
|
|
343
|
+
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.warning("blockchain_info_failed: %s", e)
|
|
346
|
+
findings.append(self._make_finding(
|
|
347
|
+
title=f"⚠️ Blockchain.info lookup failed for {address[:12]}...",
|
|
348
|
+
description=f"Could not retrieve address data: {str(e)[:200]}. View manually: https://blockchain.info/address/{address}",
|
|
349
|
+
evidence=[f"https://blockchain.info/address/{address}"],
|
|
350
|
+
confidence=0.2,
|
|
351
|
+
severity=FindingSeverity.LOW,
|
|
352
|
+
))
|
|
353
|
+
|
|
354
|
+
return findings
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# Register
|
|
358
|
+
blockchain_tool = BlockchainTool()
|
|
359
|
+
registry.register(blockchain_tool)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""CAPTCHA solver — autonomous CAPTCHA solving using vision LLM.
|
|
2
|
+
|
|
3
|
+
Architecture adapted from i-am-a-bot (AashiqRamachandran, MIT licensed):
|
|
4
|
+
Agent 1: Is this a CAPTCHA? → boolean
|
|
5
|
+
Agent 2: What type? → text / math / rotation / puzzle / select
|
|
6
|
+
Agent 3: Solve it → answer string
|
|
7
|
+
|
|
8
|
+
Uses the OpenAI-compatible vision API. Falls back to base64-encoded images
|
|
9
|
+
sent as data URLs when the model supports vision.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .base import OSINTTool
|
|
24
|
+
from .registry import registry
|
|
25
|
+
from ..core.models import Finding, FindingSource
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Provider-agnostic vision prompts (adapted from i-am-a-bot)
|
|
29
|
+
AGENT_1_PROMPT = """Analyze this image. Is it a CAPTCHA or human verification challenge?
|
|
30
|
+
Respond with ONLY valid JSON: {"is_captcha": true} or {"is_captcha": false}
|
|
31
|
+
Do not include markdown, backticks, or any text outside the JSON."""
|
|
32
|
+
|
|
33
|
+
AGENT_2_PROMPT = """Analyze this CAPTCHA image. What type is it?
|
|
34
|
+
Possible types:
|
|
35
|
+
1 = text (read and enter displayed characters)
|
|
36
|
+
2 = math (solve the equation)
|
|
37
|
+
3 = rotation (rotate image to correct angle)
|
|
38
|
+
4 = puzzle (solve a puzzle)
|
|
39
|
+
5 = select (click matching images)
|
|
40
|
+
6 = other
|
|
41
|
+
|
|
42
|
+
Respond with ONLY valid JSON: {"captcha_type": <number 1-6>}"""
|
|
43
|
+
|
|
44
|
+
AGENT_3_TEXT_PROMPT = """Read the text in this CAPTCHA image. Return ONLY the characters shown.
|
|
45
|
+
Respond with ONLY valid JSON: {"answer": "the text here"}
|
|
46
|
+
Do not include spaces if there are none in the image."""
|
|
47
|
+
|
|
48
|
+
AGENT_3_MATH_PROMPT = """Read the math equation in this CAPTCHA image.
|
|
49
|
+
Return the equation as a Python-evaluatable expression.
|
|
50
|
+
Respond with ONLY valid JSON: {"equation": "the equation here"}"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CaptchaSolver:
|
|
54
|
+
"""Solves CAPTCHAs using a vision-capable LLM."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_base: str | None = None,
|
|
59
|
+
api_key: str | None = None,
|
|
60
|
+
model: str = "deepseek-v4-pro",
|
|
61
|
+
):
|
|
62
|
+
self.api_base = api_base or os.environ.get(
|
|
63
|
+
"OPENAI_API_BASE", "https://api.deepseek.com"
|
|
64
|
+
)
|
|
65
|
+
self.api_key = api_key or os.environ.get(
|
|
66
|
+
"DEEPSEEK_API_KEY", ""
|
|
67
|
+
) or os.environ.get("OPENAI_API_KEY", "")
|
|
68
|
+
|
|
69
|
+
# Try loading from Hermes .env if key is still empty
|
|
70
|
+
if not self.api_key:
|
|
71
|
+
env_path = os.path.expanduser("~/.hermes/.env")
|
|
72
|
+
if os.path.exists(env_path):
|
|
73
|
+
with open(env_path) as f:
|
|
74
|
+
for line in f:
|
|
75
|
+
if "=" in line and not line.startswith("#"):
|
|
76
|
+
k, v = line.strip().split("=", 1)
|
|
77
|
+
k = k.strip()
|
|
78
|
+
v = v.strip().strip('"').strip("'")
|
|
79
|
+
if k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY") and v and v != "***":
|
|
80
|
+
self.api_key = v
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
self.model = model
|
|
84
|
+
|
|
85
|
+
def _encode_image(self, image_path: str) -> str:
|
|
86
|
+
"""Encode image as base64 data URL."""
|
|
87
|
+
with open(image_path, "rb") as f:
|
|
88
|
+
data = base64.b64encode(f.read()).decode("utf-8")
|
|
89
|
+
ext = Path(image_path).suffix.lower().lstrip(".")
|
|
90
|
+
mime = f"image/{ext}" if ext in ("png", "jpeg", "jpg", "gif", "webp") else "image/png"
|
|
91
|
+
return f"data:{mime};base64,{data}"
|
|
92
|
+
|
|
93
|
+
def _call_vision(self, prompt: str, image_path: str) -> str:
|
|
94
|
+
"""Call the vision API with an image and prompt."""
|
|
95
|
+
image_url = self._encode_image(image_path)
|
|
96
|
+
|
|
97
|
+
payload = {
|
|
98
|
+
"model": self.model,
|
|
99
|
+
"messages": [
|
|
100
|
+
{
|
|
101
|
+
"role": "user",
|
|
102
|
+
"content": [
|
|
103
|
+
{"type": "text", "text": prompt},
|
|
104
|
+
{"type": "image_url", "image_url": {"url": image_url}},
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
"max_tokens": 200,
|
|
109
|
+
"temperature": 0.1,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
headers = {
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
with httpx.Client(timeout=30) as client:
|
|
119
|
+
resp = client.post(
|
|
120
|
+
f"{self.api_base}/chat/completions",
|
|
121
|
+
json=payload,
|
|
122
|
+
headers=headers,
|
|
123
|
+
)
|
|
124
|
+
resp.raise_for_status()
|
|
125
|
+
data = resp.json()
|
|
126
|
+
return data["choices"][0]["message"]["content"]
|
|
127
|
+
except Exception as e:
|
|
128
|
+
raise RuntimeError(f"Vision API call failed: {e}")
|
|
129
|
+
|
|
130
|
+
def _parse_json(self, text: str) -> dict:
|
|
131
|
+
"""Extract JSON from LLM response, handling markdown and noise."""
|
|
132
|
+
# Remove markdown code blocks
|
|
133
|
+
text = re.sub(r"```(?:json)?\s*", "", text)
|
|
134
|
+
text = text.strip().strip("`")
|
|
135
|
+
|
|
136
|
+
# Try to find JSON object
|
|
137
|
+
match = re.search(r"\{[^{}]*\}", text)
|
|
138
|
+
if match:
|
|
139
|
+
return json.loads(match.group(0))
|
|
140
|
+
|
|
141
|
+
raise ValueError(f"Could not parse JSON from: {text[:100]}")
|
|
142
|
+
|
|
143
|
+
def solve(self, image_path: str) -> dict:
|
|
144
|
+
"""Solve a CAPTCHA from an image file.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
{"success": True, "answer": "...", "captcha_type": "text"}
|
|
148
|
+
{"success": False, "error": "..."}
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
# Step 1: Is this a CAPTCHA?
|
|
152
|
+
resp1 = self._call_vision(AGENT_1_PROMPT, image_path)
|
|
153
|
+
result1 = self._parse_json(resp1)
|
|
154
|
+
|
|
155
|
+
if not result1.get("is_captcha"):
|
|
156
|
+
return {"success": False, "error": "Image does not appear to be a CAPTCHA"}
|
|
157
|
+
|
|
158
|
+
# Step 2: What type?
|
|
159
|
+
resp2 = self._call_vision(AGENT_2_PROMPT, image_path)
|
|
160
|
+
result2 = self._parse_json(resp2)
|
|
161
|
+
captcha_type = int(result2.get("captcha_type", 6))
|
|
162
|
+
|
|
163
|
+
type_names = {1: "text", 2: "math", 3: "rotation", 4: "puzzle", 5: "select", 6: "other"}
|
|
164
|
+
|
|
165
|
+
# Step 3: Solve based on type
|
|
166
|
+
if captcha_type == 1: # Text
|
|
167
|
+
resp3 = self._call_vision(AGENT_3_TEXT_PROMPT, image_path)
|
|
168
|
+
result3 = self._parse_json(resp3)
|
|
169
|
+
return {
|
|
170
|
+
"success": True,
|
|
171
|
+
"answer": result3.get("answer", ""),
|
|
172
|
+
"captcha_type": "text",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
elif captcha_type == 2: # Math
|
|
176
|
+
resp3 = self._call_vision(AGENT_3_MATH_PROMPT, image_path)
|
|
177
|
+
result3 = self._parse_json(resp3)
|
|
178
|
+
equation = result3.get("equation", "0")
|
|
179
|
+
try:
|
|
180
|
+
solved = eval(equation, {"__builtins__": {}})
|
|
181
|
+
except Exception:
|
|
182
|
+
solved = equation
|
|
183
|
+
return {
|
|
184
|
+
"success": True,
|
|
185
|
+
"answer": str(solved),
|
|
186
|
+
"captcha_type": "math",
|
|
187
|
+
"equation": equation,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
else:
|
|
191
|
+
return {
|
|
192
|
+
"success": False,
|
|
193
|
+
"error": f"CAPTCHA type '{type_names.get(captcha_type, str(captcha_type))}' not yet supported",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
except Exception as e:
|
|
197
|
+
return {"success": False, "error": str(e)}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class CaptchaTool(OSINTTool):
|
|
201
|
+
"""Autonomous CAPTCHA solver for OSINT investigations."""
|
|
202
|
+
|
|
203
|
+
category = FindingSource.IMAGE_VIDEO
|
|
204
|
+
name = "captcha-solver"
|
|
205
|
+
description = "Autonomous CAPTCHA solver using vision LLM — bypasses verification on OSINT data sources"
|
|
206
|
+
free_tier_available = True
|
|
207
|
+
rate_limit_rps = 0.5
|
|
208
|
+
|
|
209
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
210
|
+
findings: list[Finding] = []
|
|
211
|
+
|
|
212
|
+
# Check if the query references a CAPTCHA image
|
|
213
|
+
image_path = self._extract_image_path(query)
|
|
214
|
+
if not image_path:
|
|
215
|
+
return findings
|
|
216
|
+
|
|
217
|
+
if not os.path.exists(image_path):
|
|
218
|
+
findings.append(
|
|
219
|
+
self._make_finding(
|
|
220
|
+
title="CAPTCHA solver: image not found",
|
|
221
|
+
description=f"Image file not found: {image_path}",
|
|
222
|
+
confidence=0.0,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
return findings
|
|
226
|
+
|
|
227
|
+
# Solve the CAPTCHA
|
|
228
|
+
solver = CaptchaSolver()
|
|
229
|
+
result = solver.solve(image_path)
|
|
230
|
+
|
|
231
|
+
if result["success"]:
|
|
232
|
+
findings.append(
|
|
233
|
+
self._make_finding(
|
|
234
|
+
title=f"✅ CAPTCHA solved ({result['captcha_type']})",
|
|
235
|
+
description=f"Answer: **{result['answer']}**",
|
|
236
|
+
confidence=0.9,
|
|
237
|
+
captcha_type=result["captcha_type"],
|
|
238
|
+
answer=result["answer"],
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
findings.append(
|
|
243
|
+
self._make_finding(
|
|
244
|
+
title=f"❌ CAPTCHA solve failed",
|
|
245
|
+
description=result.get("error", "Unknown error"),
|
|
246
|
+
confidence=0.0,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return findings
|
|
251
|
+
|
|
252
|
+
def _extract_image_path(self, text: str) -> Optional[str]:
|
|
253
|
+
"""Extract an image file path from the query."""
|
|
254
|
+
import re
|
|
255
|
+
|
|
256
|
+
# Check for file path patterns
|
|
257
|
+
patterns = [
|
|
258
|
+
r"(?:solve|captcha|image|picture|photo)\s+(?:this|the)?\s*:?\s*([^\s]+\.(?:png|jpg|jpeg|gif|webp))",
|
|
259
|
+
r"([/~].+\.(?:png|jpg|jpeg|gif|webp))",
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
for pattern in patterns:
|
|
263
|
+
match = re.search(pattern, text, re.IGNORECASE)
|
|
264
|
+
if match:
|
|
265
|
+
path = match.group(1)
|
|
266
|
+
# Expand ~
|
|
267
|
+
if path.startswith("~"):
|
|
268
|
+
path = os.path.expanduser(path)
|
|
269
|
+
return path
|
|
270
|
+
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Register
|
|
275
|
+
captcha_tool = CaptchaTool()
|
|
276
|
+
registry.register(captcha_tool)
|