Identity Handle Hunter
from kittysploit import *
import json
import os
import re
from urllib.parse import urlparse
from core.osint.identity_handles import (
API_VERIFIED_PLATFORMS,
extract_handles,
is_generic_handle,
is_valid_handle_for_platform,
normalize_profile_url,
)
from lib.protocols.http.http_client import Http_client
class Module(Auxiliary, Http_client):
__info__ = {
"name": "Identity Handle Hunter",
"author": ["KittySploit Team"],
"description": "Discover likely public profiles for a username/email/name and score confidence.",
"tags": ["osint", "identity", "passive"],
}
query = OptString("", "Identity query (username/email/name)", required=True)
query_type = OptString("username", "Query type: username|email|name", required=False)
max_results = OptString("30", "Maximum result entries to keep", required=False)
min_confidence = OptString("80", "Minimum confidence to keep a profile hit", required=False)
skip_generic_handles = OptBool(True, "Reject generic mailbox usernames (info, admin, contact, …)", required=False)
timeout = OptString("8", "HTTP timeout in seconds", required=False)
output_file = OptString("", "Optional JSON output file", required=False)
# API-verified platforms only (no HTML scraping — too many false positives).
PROFILE_PATTERNS = [
("github", "https://github.com/{handle}"),
("gitlab", "https://gitlab.com/{handle}"),
("reddit", "https://www.reddit.com/user/{handle}"),
("devto", "https://dev.to/{handle}"),
]
BROWSER_UA = "Mozilla/5.0 (compatible; KittyOSINT/1.0; +https://kittysploit.local)"
def _http_get_host(self, host, path, timeout_seconds, headers=None, ssl=True, port=443):
old_target = self.target
old_port = getattr(self, "port", 443)
old_ssl = getattr(self, "ssl", True)
try:
self.target = host
self.port = int(port)
self.ssl = ssl
return self.http_request(
method="GET",
path=path,
allow_redirects=True,
timeout=timeout_seconds,
headers=headers or {},
)
except Exception:
return None
finally:
self.target = old_target
self.port = old_port
self.ssl = old_ssl
def _http_get_url(self, url, timeout_seconds, headers=None):
parsed = urlparse(url)
host = parsed.hostname
if not host:
return None
scheme = (parsed.scheme or "https").lower()
port = parsed.port or (443 if scheme == "https" else 80)
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
return self._http_get_host(
host,
path,
timeout_seconds,
headers=headers,
ssl=(scheme == "https"),
port=port,
)
def _to_int(self, value, default_value):
try:
return max(1, int(str(value).strip()))
except Exception:
return default_value
def _extract_handles(self, query, query_type):
if not self.skip_generic_handles:
# Legacy path: keep old permissive extraction for explicit opt-out.
handles = set()
q = str(query).strip()
qtype = str(query_type).strip().lower()
if qtype == "email" and "@" in q:
local = q.split("@", 1)[0]
if local:
handles.add(local)
for variant in re.split(r"[._\-+]", local):
if len(variant) >= 3:
handles.add(variant)
elif qtype == "name":
base = re.sub(r"[^a-zA-Z0-9 ]", " ", q)
parts = [p.lower() for p in base.split() if p]
if parts:
handles.add("".join(parts))
handles.add(".".join(parts))
handles.add("_".join(parts))
if len(parts) >= 2:
handles.add(parts[0] + parts[-1])
else:
cleaned = re.sub(r"[^a-zA-Z0-9._\-]", "", q)
if cleaned:
handles.add(cleaned)
return sorted(h for h in handles if len(h) >= 3)
return extract_handles(query, query_type)
def _not_found(self, http_status=None, title=""):
return {"exists": False, "confidence": 0, "http_status": http_status, "title": title, "profile_url": ""}
def _check_github(self, handle, timeout_seconds):
headers = {"User-Agent": self.BROWSER_UA, "Accept": "application/vnd.github+json"}
resp = self._http_get_host(
"api.github.com",
f"/users/{handle}",
timeout_seconds,
headers=headers,
)
if not resp:
return self._not_found()
if resp.status_code == 404:
return self._not_found(404)
if resp.status_code != 200:
return self._not_found(resp.status_code)
try:
data = resp.json()
except Exception:
return self._not_found(resp.status_code)
login = str(data.get("login", "")).lower()
if login != handle.lower():
return self._not_found(resp.status_code)
return {
"exists": True,
"confidence": 88,
"http_status": resp.status_code,
"title": str(data.get("name") or data.get("login") or ""),
"profile_url": str(data.get("html_url") or f"https://github.com/{handle}"),
}
def _check_gitlab(self, handle, timeout_seconds):
path = f"/api/v4/users?username={handle}"
resp = self._http_get_host("gitlab.com", path, timeout_seconds, headers={"User-Agent": self.BROWSER_UA})
if not resp:
return self._not_found()
if resp.status_code != 200:
return self._not_found(resp.status_code)
try:
rows = resp.json()
except Exception:
return self._not_found(resp.status_code)
if not isinstance(rows, list) or not rows:
return self._not_found(200)
user = rows[0]
if str(user.get("username", "")).lower() != handle.lower():
return self._not_found(200)
return {
"exists": True,
"confidence": 86,
"http_status": 200,
"title": str(user.get("name") or user.get("username") or ""),
"profile_url": str(user.get("web_url") or f"https://gitlab.com/{handle}"),
}
def _parse_reddit_about(self, resp, handle):
if resp.status_code == 404:
return self._not_found(404)
if resp.status_code != 200:
return self._not_found(resp.status_code)
try:
payload = resp.json()
except Exception:
return self._not_found(resp.status_code)
if not isinstance(payload, dict):
return self._not_found(resp.status_code)
if payload.get("error"):
return self._not_found(resp.status_code, str(payload.get("message") or ""))
data = payload.get("data")
if not isinstance(data, dict) or not data.get("id"):
return self._not_found(resp.status_code)
name = str(data.get("name") or "")
if name.lower() != handle.lower():
return self._not_found(resp.status_code)
return {
"exists": True,
"confidence": 84,
"http_status": resp.status_code,
"title": name,
"profile_url": f"https://www.reddit.com/user/{name}/",
}
def _check_reddit(self, handle, timeout_seconds):
headers = {"User-Agent": self.BROWSER_UA}
for host in ("www.reddit.com", "old.reddit.com"):
path = f"/user/{handle}/about.json"
resp = self._http_get_host(host, path, timeout_seconds, headers=headers)
if not resp:
continue
meta = self._parse_reddit_about(resp, handle)
if meta.get("exists"):
return meta
if resp.status_code in (404, 403):
return meta
return self._not_found()
def _check_devto(self, handle, timeout_seconds):
path = f"/api/users/by_username?url={handle}"
headers = {"User-Agent": self.BROWSER_UA, "Accept": "application/json"}
resp = self._http_get_host("dev.to", path, timeout_seconds, headers=headers)
if not resp:
return self._not_found()
if resp.status_code == 404:
return self._not_found(404)
if resp.status_code != 200:
return self._not_found(resp.status_code)
try:
data = resp.json()
except Exception:
return self._not_found(resp.status_code)
username = str((data or {}).get("username") or "").lower()
if username != handle.lower():
return self._not_found(resp.status_code)
return {
"exists": True,
"confidence": 82,
"http_status": resp.status_code,
"title": str(data.get("name") or data.get("username") or ""),
"profile_url": f"https://dev.to/{data.get('username', handle)}",
}
def _check_profile_url(self, platform, url, handle, timeout_seconds):
if platform not in API_VERIFIED_PLATFORMS:
return {
"platform": platform,
"url": normalize_profile_url(url),
"http_status": None,
"exists": False,
"confidence": 0,
"title": "",
}
api_checkers = {
"github": self._check_github,
"gitlab": self._check_gitlab,
"reddit": self._check_reddit,
"devto": self._check_devto,
}
checker = api_checkers.get(platform)
try:
meta = checker(handle, timeout_seconds) if checker else self._not_found()
profile_url = normalize_profile_url(meta.get("profile_url") or url)
return {
"platform": platform,
"url": profile_url,
"http_status": meta.get("http_status"),
"exists": bool(meta.get("exists")),
"confidence": int(meta.get("confidence") or 0),
"title": meta.get("title") or "",
}
except Exception as e:
return {
"platform": platform,
"url": normalize_profile_url(url),
"http_status": None,
"exists": False,
"confidence": 0,
"title": "",
"error": str(e),
}
def run(self):
query = str(self.query).strip()
query_type = str(self.query_type).strip().lower() or "username"
timeout_seconds = self._to_int(self.timeout, 8)
max_results = self._to_int(self.max_results, 30)
min_confidence = min(100, self._to_int(self.min_confidence, 70))
if not query:
print_warning("No identity query provided; skipping profile discovery")
return {
"target": "",
"query_type": query_type,
"skipped": True,
"reason": "empty_query",
"handles_tested": [],
"count": 0,
"findings": [],
}
if query_type not in ("username", "email", "name"):
print_warning(f"Unknown query_type '{query_type}', fallback to 'username'")
query_type = "username"
if query_type == "email" and "@" in query:
local = query.split("@", 1)[0].strip().lower()
if self.skip_generic_handles and is_generic_handle(local):
print_warning(
f"Mailbox local-part '{local}' is too generic for profile attribution; "
"provide a person name or distinctive username instead"
)
return {
"target": query,
"query_type": query_type,
"skipped": True,
"reason": "generic_mailbox_local_part",
"handles_tested": [],
"count": 0,
"findings": [],
}
handles = self._extract_handles(query, query_type)
if not handles:
print_warning("No distinctive handle could be derived from query (generic or empty)")
return {
"target": query,
"query_type": query_type,
"skipped": True,
"reason": "no_distinctive_handle",
"handles_tested": [],
"count": 0,
"findings": [],
}
print_info(f"Target query: {query} ({query_type})")
print_info(f"Generated {len(handles)} handle candidate(s): {', '.join(handles[:5])}")
results = []
for handle in handles:
for platform, pattern in self.PROFILE_PATTERNS:
if not is_valid_handle_for_platform(handle, platform):
continue
url = pattern.format(handle=handle)
entry = self._check_profile_url(platform, url, handle, timeout_seconds)
entry["handle"] = handle
if entry.get("exists") and entry.get("confidence", 0) >= min_confidence:
results.append(entry)
unique = {}
for item in results:
key = (item.get("platform"), item.get("handle"))
if key not in unique or item.get("confidence", 0) > unique[key].get("confidence", 0):
unique[key] = item
found = sorted(unique.values(), key=lambda x: x.get("confidence", 0), reverse=True)[:max_results]
data = {
"target": query,
"query_type": query_type,
"handles_tested": handles,
"count": len(found),
"findings": found,
}
if found:
print_success(f"Found {len(found)} likely profile(s)")
for item in found[:15]:
print_info(
f" [{item.get('platform')}] {item.get('url')} "
f"(handle={item.get('handle')}, confidence={item.get('confidence')})"
)
if len(found) > 15:
print_info(f" ... and {len(found) - 15} more")
else:
print_warning("No likely profile found for tested handles")
if self.output_file:
try:
parent = os.path.dirname(str(self.output_file))
if parent:
os.makedirs(parent, exist_ok=True)
with open(str(self.output_file), "w") as f:
json.dump(data, f, indent=2)
print_success(f"Results saved to {self.output_file}")
except Exception as e:
print_error(f"Failed to save output: {e}")
return data
def get_graph_nodes(self, data):
if not isinstance(data, dict) or "error" in data:
return [], []
target = data.get("target", "identity")
nodes = []
edges = []
findings = data.get("findings", [])[:25]
for idx, item in enumerate(findings):
nid = f"profile_{idx}"
label = f"@{item.get('handle')} on {item.get('platform')} ({item.get('confidence', 0)})"
nodes.append({
"id": nid,
"label": label,
"group": "hostname",
"icon": "👤",
})
edges.append({
"from": target,
"to": nid,
"label": "identity",
})
return nodes, edges
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
29 Jun 2026 09:40Current
5.8Medium risk
Vulners AI Score5.8