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,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)
|