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,129 @@
1
+ """Shodan tool — internet-wide infrastructure scanning for OSINT.
2
+
3
+ Discovers exposed services, industrial control systems, vulnerable
4
+ infrastructure associated with a target. Paid API required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ from .base import OSINTTool
12
+ from .registry import registry
13
+ from ..core.models import Finding, FindingSource
14
+
15
+ logger = logging.getLogger("watson.shodan")
16
+
17
+
18
+ class ShodanTool(OSINTTool):
19
+ """Internet scanner — discovers exposed services, ICS, vulnerable infra."""
20
+
21
+ category = FindingSource.OSINT
22
+ name = "shodan"
23
+ description = "Shodan internet scanner — exposed services, industrial control systems, vulnerable infrastructure"
24
+ free_tier_available = False
25
+ rate_limit_rps = 1.0
26
+
27
+ SHODAN_HOST_SEARCH = "https://api.shodan.io/shodan/host/search"
28
+
29
+ async def investigate(self, query: str, context: str = "") -> list[Finding]:
30
+ """Search Shodan for exposed infrastructure related to the target."""
31
+ findings: list[Finding] = []
32
+
33
+ from watson.api_keys import get_key
34
+
35
+ api_key = get_key("shodan")
36
+ if not api_key:
37
+ findings.append(self._make_finding(
38
+ title="🔒 Shodan API key not configured",
39
+ description=(
40
+ "Shodan provides internet-wide infrastructure scanning — exposed "
41
+ "services, industrial control systems, vulnerable ports. "
42
+ "Install your API key in Settings → API Vault to enable.\n\n"
43
+ "Get a key: https://account.shodan.io/"
44
+ ),
45
+ confidence=0.0,
46
+ source_url="https://account.shodan.io/",
47
+ ))
48
+ return findings
49
+
50
+ # Extract searchable terms
51
+ search_term = self._extract_search_term(query)
52
+ if not search_term:
53
+ return findings
54
+
55
+ try:
56
+ import httpx
57
+
58
+ async with httpx.AsyncClient(timeout=20) as client:
59
+ resp = await client.get(
60
+ f"{self.SHODAN_HOST_SEARCH}?key={api_key}&query={search_term}&facets=org,os,port",
61
+ )
62
+ resp.raise_for_status()
63
+ data = resp.json()
64
+
65
+ matches = data.get("matches", [])
66
+ total = data.get("total", 0)
67
+
68
+ if matches:
69
+ entries = []
70
+ for m in matches[:8]:
71
+ ip = m.get("ip_str", "?")
72
+ port = m.get("port", "?")
73
+ org = m.get("org", "?")
74
+ os_name = m.get("os", "?") or "?"
75
+ hostnames = ", ".join(m.get("hostnames", [])[:2]) or "none"
76
+ product = m.get("product", m.get("_shodan", {}).get("module", "unknown"))
77
+
78
+ entries.append(
79
+ f"- **{ip}:{port}** — {product} ({org})\n"
80
+ f" OS: {os_name} | Hostnames: {hostnames}"
81
+ )
82
+
83
+ findings.append(self._make_finding(
84
+ title=f"🌐 Shodan: {len(matches)} exposed services for '{search_term}' ({total} total)",
85
+ description="\n".join(entries[:6]),
86
+ confidence=0.85,
87
+ evidence=[f"https://www.shodan.io/search?query={search_term}"],
88
+ total_results=total,
89
+ ))
90
+ else:
91
+ findings.append(self._make_finding(
92
+ title=f"🌐 Shodan: No exposed services found for '{search_term}'",
93
+ description="No internet-facing infrastructure matching the query was discovered.",
94
+ confidence=0.6,
95
+ ))
96
+
97
+ except Exception as e:
98
+ logger.warning("shodan_search_failed: %s", e)
99
+ findings.append(self._make_finding(
100
+ title=f"⚠ Shodan search failed: {str(e)[:100]}",
101
+ description="API call failed. Check your key or try again later.",
102
+ confidence=0.0,
103
+ ))
104
+
105
+ return findings
106
+
107
+ @staticmethod
108
+ def _extract_search_term(query: str) -> str:
109
+ """Extract domain, IP, or org name for Shodan search."""
110
+ import re
111
+
112
+ # IP address
113
+ ip_match = re.search(r'\b(?:\d{1,3}\.){3}\d{1,3}\b', query)
114
+ if ip_match:
115
+ return ip_match.group(0)
116
+
117
+ # Domain
118
+ domain_match = re.search(r'(?:org|domain|hostname)\s*[:=]?\s*([a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,})', query, re.IGNORECASE)
119
+ if domain_match:
120
+ return domain_match.group(1)
121
+
122
+ # Use first 3 words as org search
123
+ words = query.split()[:3]
124
+ return f'org:"{" ".join(words)}"'
125
+
126
+
127
+ # Register
128
+ shodan_tool = ShodanTool()
129
+ registry.register(shodan_tool)
@@ -0,0 +1,160 @@
1
+ """Social Media tool — cross-platform profile discovery and analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
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
+ from ..utils.helpers import clean_username
12
+
13
+
14
+ # Common social media platforms and their profile URL patterns
15
+ SOCIAL_PLATFORMS = [
16
+ ("Twitter/X", "https://x.com/{username}"),
17
+ ("Instagram", "https://instagram.com/{username}"),
18
+ ("LinkedIn", "https://linkedin.com/in/{username}"),
19
+ ("GitHub", "https://github.com/{username}"),
20
+ ("Reddit", "https://reddit.com/user/{username}"),
21
+ ("TikTok", "https://tiktok.com/@{username}"),
22
+ ("YouTube", "https://youtube.com/@{username}"),
23
+ ("Facebook", "https://facebook.com/{username}"),
24
+ ("Telegram", "https://t.me/{username}"),
25
+ ("Medium", "https://medium.com/@{username}"),
26
+ ("Substack", "https://{username}.substack.com"),
27
+ ("Pinterest", "https://pinterest.com/{username}"),
28
+ ("Twitch", "https://twitch.tv/{username}"),
29
+ ("Snapchat", "https://snapchat.com/add/{username}"),
30
+ ]
31
+
32
+
33
+ class SocialMediaTool(OSINTTool):
34
+ """Discover social media profiles across platforms by username."""
35
+
36
+ category = FindingSource.SOCIAL_MEDIA
37
+ name = "social-media"
38
+ description = "Cross-platform profile discovery, username search, social presence mapping"
39
+ free_tier_available = True
40
+ rate_limit_rps = 2.0
41
+
42
+ async def investigate(self, query: str, context: str = "") -> list[Finding]:
43
+ findings: list[Finding] = []
44
+
45
+ # Extract potential usernames
46
+ usernames = self._extract_usernames(query)
47
+ if not usernames:
48
+ return findings
49
+
50
+ client = get_client(rate_limit=self.rate_limit_rps)
51
+
52
+ for username in usernames[:3]: # Max 3 usernames
53
+ username_clean = clean_username(username)
54
+
55
+ # Check profile URLs in parallel
56
+ platforms_found: list[str] = []
57
+ tasks = []
58
+
59
+ for platform_name, url_template in SOCIAL_PLATFORMS:
60
+ url = url_template.format(username=username_clean)
61
+ tasks.append(self._check_profile(client, platform_name, url))
62
+
63
+ results = await asyncio.gather(*tasks)
64
+
65
+ for name, url, exists in results:
66
+ if exists:
67
+ platforms_found.append(f"[{name}]({url})")
68
+
69
+ if platforms_found:
70
+ findings.append(
71
+ self._make_finding(
72
+ title=f"👤 Social profiles for '{username_clean}'",
73
+ description=(
74
+ f"Found {len(platforms_found)} profiles across platforms:\n"
75
+ + "\n".join(f"- {p}" for p in platforms_found)
76
+ ),
77
+ confidence=0.85 if len(platforms_found) >= 3 else 0.5,
78
+ username=username_clean,
79
+ platform_count=len(platforms_found),
80
+ platforms=platforms_found,
81
+ )
82
+ )
83
+ else:
84
+ findings.append(
85
+ self._make_finding(
86
+ title=f"No public profiles found for '{username_clean}'",
87
+ description=(
88
+ f"Checked {len(SOCIAL_PLATFORMS)} platforms. "
89
+ "The username may not exist, be private, or use a different handle."
90
+ ),
91
+ confidence=0.3,
92
+ username=username_clean,
93
+ )
94
+ )
95
+
96
+ # Generate a profile URL list regardless (for manual checking)
97
+ all_urls = [
98
+ f"[{name}]({url.format(username=username_clean)})"
99
+ for name, url in SOCIAL_PLATFORMS[:8]
100
+ ]
101
+ findings.append(
102
+ self._make_finding(
103
+ title=f"🔗 Profile links for '{username_clean}' (manual check)",
104
+ description="Quick links to check manually:\n" + "\n".join(f"- {u}" for u in all_urls),
105
+ confidence=0.6,
106
+ username=username_clean,
107
+ )
108
+ )
109
+
110
+ return findings
111
+
112
+ async def _check_profile(
113
+ self, client, platform_name: str, url: str
114
+ ) -> tuple[str, str, bool]:
115
+ """Check if a social media profile exists. Returns (name, url, exists)."""
116
+ try:
117
+ response = await client.get(url)
118
+ # A 200 means the profile exists and is public
119
+ # Some platforms return 200 with a "not found" page, but this is a good heuristic
120
+ return platform_name, url, True
121
+ except Exception:
122
+ return platform_name, url, False
123
+
124
+ def _extract_usernames(self, text: str) -> list[str]:
125
+ """Extract potential usernames from query text."""
126
+ usernames = []
127
+
128
+ # Look for @handles
129
+ import re
130
+
131
+ handles = re.findall(r"@(\w{3,30})", text)
132
+ usernames.extend(handles)
133
+
134
+ # Look for "username X" or "handle X" patterns
135
+ patterns = [
136
+ r"(?:username|handle|profile|account)\s+(?:is\s+)?['\"]?(\w{3,30})['\"]?",
137
+ r"(?:find|search|look\s+up|check)\s+(?:the\s+)?(?:social\s+)?(?:media\s+)?(?:profile|account)(?:\s+for)?\s+['\"]?(\w{3,30})['\"]?",
138
+ ]
139
+ for pattern in patterns:
140
+ match = re.search(pattern, text, re.IGNORECASE)
141
+ if match:
142
+ usernames.append(match.group(1))
143
+
144
+ # Fallback: treat any single CamelCase word as a potential username
145
+ if not usernames:
146
+ word_match = re.search(r'\b([A-Za-z][A-Za-z0-9_]{2,30})\b', text)
147
+ if word_match:
148
+ word = word_match.group(1)
149
+ # Don't use common investigative words as usernames
150
+ if word.lower() not in ("who", "what", "where", "when", "why", "how",
151
+ "the", "and", "for", "with", "company", "person", "domain",
152
+ "investigate", "research", "search", "find", "look", "check"):
153
+ usernames.append(word)
154
+
155
+ return list(dict.fromkeys(usernames)) # Deduplicated
156
+
157
+
158
+ # Register
159
+ social_media_tool = SocialMediaTool()
160
+ registry.register(social_media_tool)
@@ -0,0 +1,291 @@
1
+ """Websites & Domains tool — WHOIS, Wayback Machine, DNS, SSL certificates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import datetime
7
+
8
+ from .base import OSINTTool
9
+ from .registry import registry
10
+ from ..core.models import Finding, FindingSeverity, FindingSource
11
+ from ..utils.http import get_client
12
+ from ..utils.helpers import extract_domain
13
+
14
+
15
+ class WebsitesTool(OSINTTool):
16
+ """Investigate domains — WHOIS, Internet Archive, SSL certificates, DNS records."""
17
+
18
+ category = FindingSource.WEBSITES
19
+ name = "websites-domains"
20
+ description = "WHOIS lookup, Wayback Machine history, SSL certificates (crt.sh), subdomain discovery"
21
+ free_tier_available = True
22
+ rate_limit_rps = 3.0
23
+
24
+ WAYBACK_CDX = "https://web.archive.org/cdx/search/cdx"
25
+ CRTSH_API = "https://crt.sh/"
26
+ DNS_OVER_HTTPS = "https://dns.google/resolve"
27
+
28
+ async def investigate(self, query: str, context: str = "") -> list[Finding]:
29
+ findings: list[Finding] = []
30
+
31
+ domains = self._extract_domains(query)
32
+ if not domains:
33
+ return findings
34
+
35
+ import httpx
36
+ for domain in domains[:3]:
37
+ clean = extract_domain(domain)
38
+
39
+ async def _wayback():
40
+ try:
41
+ params = {"url": f"*.{clean}/*", "output": "json", "limit": 5, "fl": "timestamp,original"}
42
+ async with httpx.AsyncClient(timeout=10) as c:
43
+ resp = await c.get(self.WAYBACK_CDX, params=params)
44
+ if resp.status_code == 200:
45
+ snapshots = resp.json()
46
+ if isinstance(snapshots, list) and snapshots:
47
+ first = snapshots[0][0] if snapshots[0] else "unknown"
48
+ last = snapshots[-1][0] if snapshots[-1] else "unknown"
49
+ return self._make_finding(
50
+ title=f"📚 Wayback Machine: {clean}",
51
+ description=f"First archived: {first[:4]}-{first[4:6]}-{first[6:8]}. "
52
+ f"Latest snapshot: {last[:4]}-{last[4:6]}-{last[6:8]}. "
53
+ f"Total unique snapshots in recent window: {len(snapshots)}.",
54
+ evidence=[f"https://web.archive.org/web/*/{clean}"],
55
+ confidence=0.95,
56
+ domain=clean,
57
+ )
58
+ except Exception:
59
+ pass
60
+ return None
61
+
62
+ async def _crtsh():
63
+ try:
64
+ url = f"{self.CRTSH_API}?q=%25.{clean}&output=json"
65
+ async with httpx.AsyncClient(timeout=15) as c:
66
+ resp = await c.get(url, headers={"User-Agent": "WatsonOSINT/0.3"})
67
+ if resp.status_code == 200:
68
+ certs = resp.json()
69
+ if isinstance(certs, list) and certs:
70
+ subdomains = set()
71
+ for cert in certs[:50]:
72
+ names = cert.get("name_value", "")
73
+ for n in names.split("\\n"):
74
+ n = n.strip()
75
+ if n and n != clean and not n.startswith("*."):
76
+ subdomains.add(n)
77
+ return self._make_finding(
78
+ title=f"🔐 SSL certificates: {clean}",
79
+ description=f"{len(certs)} certificates found. "
80
+ f"{len(subdomains)} unique subdomains: {', '.join(sorted(list(subdomains))[:10])}",
81
+ evidence=[f"https://crt.sh/?q=%.{clean}"],
82
+ confidence=0.85,
83
+ domain=clean,
84
+ )
85
+ elif resp.status_code >= 400:
86
+ return self._make_finding(
87
+ title=f"⚠️ crt.sh lookup failed for {clean}",
88
+ description=f"SSL certificate lookup error: HTTP {resp.status_code}. "
89
+ f"Try manually: https://crt.sh/?q=%.{clean}",
90
+ evidence=[f"https://crt.sh/?q=%.{clean}"],
91
+ confidence=0.1,
92
+ domain=clean,
93
+ )
94
+ except Exception:
95
+ pass
96
+ return None
97
+
98
+ async def _dns():
99
+ try:
100
+ async with httpx.AsyncClient(timeout=10) as c:
101
+ a_resp = await c.get(f"{self.DNS_OVER_HTTPS}?name={clean}&type=A")
102
+ mx_resp = await c.get(f"{self.DNS_OVER_HTTPS}?name={clean}&type=MX")
103
+ ns_resp = await c.get(f"{self.DNS_OVER_HTTPS}?name={clean}&type=NS")
104
+ txt_resp = await c.get(f"{self.DNS_OVER_HTTPS}?name={clean}&type=TXT")
105
+ a_data = a_resp.json() if a_resp.status_code == 200 else {}
106
+ mx_data = mx_resp.json() if mx_resp.status_code == 200 else {}
107
+ ns_data = ns_resp.json() if ns_resp.status_code == 200 else {}
108
+ txt_data = txt_resp.json() if txt_resp.status_code == 200 else {}
109
+ a_records = [a.get("data", "") for a in a_data.get("Answer", [])]
110
+ mx_records = [m.get("data", "") for m in mx_data.get("Answer", [])]
111
+ ns_records = [n.get("data", "") for n in ns_data.get("Answer", [])]
112
+ txt_records = [t.get("data", "") for t in txt_data.get("Answer", [])]
113
+ if a_records or mx_records:
114
+ return self._make_finding(
115
+ title=f"🌐 DNS records for {clean}",
116
+ description=f"- A: {', '.join(a_records[:3])} "
117
+ f"- MX: {', '.join(mx_records[:3])} "
118
+ f"- NS: {', '.join(ns_records[:3])} "
119
+ f"- TXT: {', '.join(txt_records[:3])}",
120
+ evidence=[f"https://dns.google/resolve?name={clean}"],
121
+ confidence=0.9,
122
+ domain=clean,
123
+ )
124
+ except Exception:
125
+ pass
126
+ return None
127
+
128
+ wayback, crt, dns = await asyncio.gather(
129
+ _wayback(), _crtsh(), _dns(),
130
+ )
131
+ if wayback: findings.append(wayback)
132
+ if crt: findings.append(crt)
133
+ if dns: findings.append(dns)
134
+
135
+ return findings
136
+
137
+ async def _check_wayback(self, client, domain: str) -> Finding | None:
138
+ """Check Internet Archive Wayback Machine for domain history."""
139
+ try:
140
+ params = {
141
+ "url": f"*.{domain}/*",
142
+ "output": "json",
143
+ "limit": 5,
144
+ "fl": "timestamp,original,statuscode",
145
+ "collapse": "digest",
146
+ }
147
+ data = await client.get_json(self.WAYBACK_CDX, params=params)
148
+
149
+ if isinstance(data, list) and len(data) > 1:
150
+ # Skip header row (first element is column names)
151
+ rows = data[1:] if isinstance(data[0], list) and data[0][0] == "timestamp" else data
152
+ if not rows:
153
+ return None
154
+ first = rows[-1]
155
+ latest = rows[0]
156
+
157
+ first_date = datetime.strptime(first[0][:8], "%Y%m%d").strftime("%b %d, %Y")
158
+ latest_date = datetime.strptime(latest[0][:8], "%Y%m%d").strftime("%b %d, %Y")
159
+
160
+ return self._make_finding(
161
+ title=f"📚 Wayback Machine: {domain}",
162
+ description=(
163
+ f"First archived: {first_date}. "
164
+ f"Latest snapshot: {latest_date}. "
165
+ f"Total unique snapshots in recent window: {len(data)}."
166
+ ),
167
+ evidence=[
168
+ f"https://web.archive.org/web/*/{domain}",
169
+ f"https://web.archive.org/web/{latest[0]}/{latest[1]}",
170
+ ],
171
+ confidence=0.95,
172
+ domain=domain,
173
+ first_snapshot=first[0],
174
+ latest_snapshot=latest[0],
175
+ )
176
+ except Exception as e:
177
+ return self._make_finding(
178
+ title=f"⚠️ Wayback Machine unavailable for {domain}",
179
+ description=f"Could not retrieve archive data: {str(e)[:200]}",
180
+ confidence=0.0,
181
+ severity=FindingSeverity.LOW,
182
+ )
183
+ return None
184
+
185
+ async def _check_crtsh(self, client, domain: str) -> Finding | None:
186
+ """Check SSL certificate transparency logs via crt.sh."""
187
+ try:
188
+ url = f"{self.CRTSH_API}?q=%25.{domain}&output=json"
189
+ data = await client.get_json(url)
190
+
191
+ if isinstance(data, list) and data:
192
+ # Extract unique subdomains
193
+ subdomains: set[str] = set()
194
+ for entry in data[:50]:
195
+ names = entry.get("name_value", "").split("\n")
196
+ for name in names:
197
+ name = name.strip().lstrip("*.")
198
+ if name and domain in name:
199
+ subdomains.add(name)
200
+
201
+ subdomain_list = sorted(subdomains)[:10]
202
+
203
+ return self._make_finding(
204
+ title=f"🔒 SSL certs: {len(subdomains)} subdomains found for {domain}",
205
+ description=(
206
+ f"Discovered {len(subdomains)} unique names via certificate transparency. "
207
+ f"First 10:\n" + "\n".join(f"- `{s}`" for s in subdomain_list)
208
+ ),
209
+ evidence=[f"https://crt.sh/?q=%.{domain}"],
210
+ confidence=0.9,
211
+ domain=domain,
212
+ subdomain_count=len(subdomains),
213
+ )
214
+ except Exception as e:
215
+ return self._make_finding(
216
+ title=f"⚠️ crt.sh lookup failed for {domain}",
217
+ description=f"SSL certificate lookup error: {str(e)[:200]}. Try manually: https://crt.sh/?q=%.{domain}",
218
+ evidence=[f"https://crt.sh/?q=%.{domain}"],
219
+ confidence=0.1,
220
+ severity=FindingSeverity.LOW,
221
+ )
222
+ return None
223
+
224
+ async def _check_dns(self, client, domain: str) -> Finding | None:
225
+ """Check DNS records via Google DNS-over-HTTPS."""
226
+ try:
227
+ async def _query(rt: str) -> str | None:
228
+ try:
229
+ data = await client.get_json(
230
+ self.DNS_OVER_HTTPS, params={"name": domain, "type": rt}
231
+ )
232
+ answers = data.get("Answer", [])
233
+ if answers:
234
+ values = [a["data"] for a in answers[:3]]
235
+ return f"{rt}: {', '.join(values)}"
236
+ except Exception:
237
+ pass
238
+ return None
239
+
240
+ results = await asyncio.gather(
241
+ *[_query(rt) for rt in ["A", "AAAA", "MX", "NS", "TXT"]]
242
+ )
243
+ records_found = [r for r in results if r]
244
+
245
+ if records_found:
246
+ return self._make_finding(
247
+ title=f"🌐 DNS records for {domain}",
248
+ description="\n".join(f"- {r}" for r in records_found),
249
+ confidence=0.9,
250
+ domain=domain,
251
+ )
252
+ else:
253
+ return self._make_finding(
254
+ title=f"⚠️ No DNS records found for {domain}",
255
+ description="DNS-over-HTTPS returned no records. Domain may not resolve or is parked.",
256
+ confidence=0.3,
257
+ severity=FindingSeverity.LOW,
258
+ )
259
+ except Exception as e:
260
+ return self._make_finding(
261
+ title=f"⚠️ DNS lookup failed for {domain}",
262
+ description=f"DNS-over-HTTPS error: {str(e)[:200]}",
263
+ confidence=0.0,
264
+ severity=FindingSeverity.LOW,
265
+ )
266
+
267
+ def _extract_domains(self, text: str) -> list[str]:
268
+ """Extract domain names from text. Falls back to deriving domain from single-word queries."""
269
+ import re
270
+
271
+ pattern = r"(?:https?://)?(?:www\.)?([a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?)"
272
+ matches = re.findall(pattern, text)
273
+ domains = list(dict.fromkeys(matches))
274
+
275
+ # If no domain found and query looks like a company/product name, derive it
276
+ if not domains:
277
+ # Check for single capitalized word or CamelCase (e.g. "OpenAI", "DeepSeek")
278
+ word_match = re.search(r'\b([A-Za-z][A-Za-z0-9]{2,}(?:\.[a-z]{2,})?)\b', text)
279
+ if word_match:
280
+ word = word_match.group(1).lower()
281
+ if '.' not in word:
282
+ domains = [f"{word}.com", f"{word}.org", f"{word}.io"]
283
+ else:
284
+ domains = [word]
285
+
286
+ return domains
287
+
288
+
289
+ # Register
290
+ websites_tool = WebsitesTool()
291
+ registry.register(websites_tool)