osintengine 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
@@ -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)