tgsherlock 1.0.0__tar.gz

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 (47) hide show
  1. tgsherlock-1.0.0/LICENSE +22 -0
  2. tgsherlock-1.0.0/PKG-INFO +67 -0
  3. tgsherlock-1.0.0/README.md +48 -0
  4. tgsherlock-1.0.0/pyproject.toml +33 -0
  5. tgsherlock-1.0.0/setup.cfg +4 -0
  6. tgsherlock-1.0.0/src/tgsherlock/__init__.py +2 -0
  7. tgsherlock-1.0.0/src/tgsherlock/__main__.py +4 -0
  8. tgsherlock-1.0.0/src/tgsherlock/alerts.py +250 -0
  9. tgsherlock-1.0.0/src/tgsherlock/analysis.py +57 -0
  10. tgsherlock-1.0.0/src/tgsherlock/assets/tgsherlock.png +0 -0
  11. tgsherlock-1.0.0/src/tgsherlock/bus.py +24 -0
  12. tgsherlock-1.0.0/src/tgsherlock/cli.py +117 -0
  13. tgsherlock-1.0.0/src/tgsherlock/config.py +97 -0
  14. tgsherlock-1.0.0/src/tgsherlock/db.py +209 -0
  15. tgsherlock-1.0.0/src/tgsherlock/desktop.py +75 -0
  16. tgsherlock-1.0.0/src/tgsherlock/paths.py +40 -0
  17. tgsherlock-1.0.0/src/tgsherlock/server.py +565 -0
  18. tgsherlock-1.0.0/src/tgsherlock/static/app.css +349 -0
  19. tgsherlock-1.0.0/src/tgsherlock/static/app.js +1489 -0
  20. tgsherlock-1.0.0/src/tgsherlock/static/favicon.png +0 -0
  21. tgsherlock-1.0.0/src/tgsherlock/static/fonts/ChakraPetch-600-latin-ext.woff2 +0 -0
  22. tgsherlock-1.0.0/src/tgsherlock/static/fonts/ChakraPetch-600-latin.woff2 +0 -0
  23. tgsherlock-1.0.0/src/tgsherlock/static/fonts/ChakraPetch-700-latin-ext.woff2 +0 -0
  24. tgsherlock-1.0.0/src/tgsherlock/static/fonts/ChakraPetch-700-latin.woff2 +0 -0
  25. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
  26. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexMono-400-latin.woff2 +0 -0
  27. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
  28. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexMono-500-latin.woff2 +0 -0
  29. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-400-latin-ext.woff2 +0 -0
  30. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-400-latin.woff2 +0 -0
  31. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-500-latin-ext.woff2 +0 -0
  32. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-500-latin.woff2 +0 -0
  33. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-600-latin-ext.woff2 +0 -0
  34. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-600-latin.woff2 +0 -0
  35. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-700-latin-ext.woff2 +0 -0
  36. tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-700-latin.woff2 +0 -0
  37. tgsherlock-1.0.0/src/tgsherlock/static/fonts.css +16 -0
  38. tgsherlock-1.0.0/src/tgsherlock/static/index.html +17 -0
  39. tgsherlock-1.0.0/src/tgsherlock/static/logo-sm.png +0 -0
  40. tgsherlock-1.0.0/src/tgsherlock/static/logo.png +0 -0
  41. tgsherlock-1.0.0/src/tgsherlock/tg.py +1087 -0
  42. tgsherlock-1.0.0/src/tgsherlock.egg-info/PKG-INFO +67 -0
  43. tgsherlock-1.0.0/src/tgsherlock.egg-info/SOURCES.txt +45 -0
  44. tgsherlock-1.0.0/src/tgsherlock.egg-info/dependency_links.txt +1 -0
  45. tgsherlock-1.0.0/src/tgsherlock.egg-info/entry_points.txt +2 -0
  46. tgsherlock-1.0.0/src/tgsherlock.egg-info/requires.txt +3 -0
  47. tgsherlock-1.0.0/src/tgsherlock.egg-info/top_level.txt +1 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Iván Castañeda (original OSINTelegram)
4
+ Copyright (c) 2026 TGSherlock contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: tgsherlock
3
+ Version: 1.0.0
4
+ Summary: Local web panel for Telegram OSINT: live monitoring, alerts, user tracking and keyword sweeps.
5
+ License: MIT
6
+ Keywords: telegram,osint,monitoring,threat-intelligence,telethon
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Topic :: Security
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: telethon<2,>=1.34
16
+ Requires-Dist: aiohttp>=3.9
17
+ Requires-Dist: qrcode>=7.4
18
+ Dynamic: license-file
19
+
20
+ <p align="center">
21
+ <img src="https://raw.githubusercontent.com/emrekybs/tgsherlock/main/src/tgsherlock/static/logo-sm.png" width="120" alt="TGSherlock logo">
22
+ </p>
23
+ <h1 align="center">TGSherlock</h1>
24
+ <p align="center">Telegram OSINT in a local web panel: live monitoring, alerts, user tracking and keyword sweeps.</p>
25
+
26
+ ![Dashboard](https://raw.githubusercontent.com/emrekybs/tgsherlock/main/docs/screenshots/dashboard.png)
27
+ ![User profile](https://raw.githubusercontent.com/emrekybs/tgsherlock/main/docs/screenshots/profile.png)
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pipx install tgsherlock
33
+ ```
34
+
35
+ From a local clone: `pipx install .` (if `tgsherlock` is not found afterwards, run `pipx ensurepath` and open a new terminal).
36
+
37
+ ## Run
38
+
39
+ ```bash
40
+ tgsherlock
41
+ ```
42
+
43
+ The panel opens in your browser at `http://127.0.0.1:8765`. On Linux, the first run also adds **TGSherlock** to your application menu, so you can start it with a click.
44
+
45
+ **First run:** create an app at [my.telegram.org](https://my.telegram.org/apps) → *API development tools*, enter your **API ID** and **API Hash**, then scan the QR code with Telegram (*Settings → Devices → Link Desktop Device*). Phone-number login is available too.
46
+
47
+ ## Features
48
+
49
+ - **Live Monitor**: real-time messages from chosen users, groups and channels, with optional media download.
50
+ - **Alerts**: watch a user, a group or every chat for any message, keywords, regex, media or indicators. Get notified in-app, on the desktop, by email (SMTP) or by webhook, with digest throttling.
51
+ - **User Profile**: bio, usernames, flags, shared groups, first and last seen per group, hourly activity and a dated message timeline.
52
+ - **Search**: users, groups and channels, shown with their profile photos.
53
+ - **Group History · Keyword Sweep · User Tracker**: background jobs with progress and automatic FloodWait handling.
54
+ - **Entities**: emails, phones, URLs, IPs, crypto wallets, invite links and hashtags, extracted automatically.
55
+ - **Archive & export**: filter everything you collected and export it as CSV, JSON or an HTML report.
56
+ - **Multiple accounts**: add, switch and sign out from the sidebar.
57
+
58
+ Options: `--port 8790`, `--no-browser`, `--install-desktop`, `--uninstall-desktop`.
59
+ Data stays local: `~/.config/tgsherlock` (settings, sessions) and `~/.local/share/tgsherlock` (databases, media).
60
+
61
+ ## Responsible use
62
+
63
+ Built for defensive OSINT and threat intelligence. TGSherlock only sees what your own Telegram account can see. Follow local law and Telegram's Terms of Service.
64
+
65
+ ## License
66
+
67
+ MIT. Based on [OSINTelegram](https://github.com/Ivancastl/OSINTelegram) by Iván Castañeda.
@@ -0,0 +1,48 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/emrekybs/tgsherlock/main/src/tgsherlock/static/logo-sm.png" width="120" alt="TGSherlock logo">
3
+ </p>
4
+ <h1 align="center">TGSherlock</h1>
5
+ <p align="center">Telegram OSINT in a local web panel: live monitoring, alerts, user tracking and keyword sweeps.</p>
6
+
7
+ ![Dashboard](https://raw.githubusercontent.com/emrekybs/tgsherlock/main/docs/screenshots/dashboard.png)
8
+ ![User profile](https://raw.githubusercontent.com/emrekybs/tgsherlock/main/docs/screenshots/profile.png)
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pipx install tgsherlock
14
+ ```
15
+
16
+ From a local clone: `pipx install .` (if `tgsherlock` is not found afterwards, run `pipx ensurepath` and open a new terminal).
17
+
18
+ ## Run
19
+
20
+ ```bash
21
+ tgsherlock
22
+ ```
23
+
24
+ The panel opens in your browser at `http://127.0.0.1:8765`. On Linux, the first run also adds **TGSherlock** to your application menu, so you can start it with a click.
25
+
26
+ **First run:** create an app at [my.telegram.org](https://my.telegram.org/apps) → *API development tools*, enter your **API ID** and **API Hash**, then scan the QR code with Telegram (*Settings → Devices → Link Desktop Device*). Phone-number login is available too.
27
+
28
+ ## Features
29
+
30
+ - **Live Monitor**: real-time messages from chosen users, groups and channels, with optional media download.
31
+ - **Alerts**: watch a user, a group or every chat for any message, keywords, regex, media or indicators. Get notified in-app, on the desktop, by email (SMTP) or by webhook, with digest throttling.
32
+ - **User Profile**: bio, usernames, flags, shared groups, first and last seen per group, hourly activity and a dated message timeline.
33
+ - **Search**: users, groups and channels, shown with their profile photos.
34
+ - **Group History · Keyword Sweep · User Tracker**: background jobs with progress and automatic FloodWait handling.
35
+ - **Entities**: emails, phones, URLs, IPs, crypto wallets, invite links and hashtags, extracted automatically.
36
+ - **Archive & export**: filter everything you collected and export it as CSV, JSON or an HTML report.
37
+ - **Multiple accounts**: add, switch and sign out from the sidebar.
38
+
39
+ Options: `--port 8790`, `--no-browser`, `--install-desktop`, `--uninstall-desktop`.
40
+ Data stays local: `~/.config/tgsherlock` (settings, sessions) and `~/.local/share/tgsherlock` (databases, media).
41
+
42
+ ## Responsible use
43
+
44
+ Built for defensive OSINT and threat intelligence. TGSherlock only sees what your own Telegram account can see. Follow local law and Telegram's Terms of Service.
45
+
46
+ ## License
47
+
48
+ MIT. Based on [OSINTelegram](https://github.com/Ivancastl/OSINTelegram) by Iván Castañeda.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tgsherlock"
7
+ version = "1.0.0"
8
+ description = "Local web panel for Telegram OSINT: live monitoring, alerts, user tracking and keyword sweeps."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["telegram", "osint", "monitoring", "threat-intelligence", "telethon"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Environment :: Web Environment",
18
+ "Topic :: Security",
19
+ ]
20
+ dependencies = [
21
+ "telethon>=1.34,<2",
22
+ "aiohttp>=3.9",
23
+ "qrcode>=7.4",
24
+ ]
25
+
26
+ [project.scripts]
27
+ tgsherlock = "tgsherlock.cli:main"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ tgsherlock = ["static/*", "static/fonts/*", "assets/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """TGSherlock — local web panel for Telegram OSINT."""
2
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,250 @@
1
+ """Alert rules: matching, e-mail (SMTP) and webhook delivery with throttling."""
2
+ import asyncio
3
+ import html
4
+ import logging
5
+ import re
6
+ import smtplib
7
+ import ssl
8
+ import time
9
+ from email.message import EmailMessage
10
+ from email.utils import formatdate, make_msgid
11
+
12
+ import aiohttp
13
+
14
+ log = logging.getLogger("tgsherlock.alerts")
15
+
16
+ TRIGGER_LABELS = {
17
+ "any": "Any message",
18
+ "keywords": "Keywords",
19
+ "regex": "Regex",
20
+ "media": "Has media",
21
+ "entity": "Entity found",
22
+ }
23
+
24
+ _rx_cache = {}
25
+
26
+
27
+ def _regex(pattern):
28
+ if pattern not in _rx_cache:
29
+ try:
30
+ _rx_cache[pattern] = re.compile(pattern, re.I)
31
+ except re.error:
32
+ _rx_cache[pattern] = None
33
+ return _rx_cache[pattern]
34
+
35
+
36
+ def validate_rule(r: dict):
37
+ if r.get("watch") not in ("user", "chat", "anywhere"):
38
+ return "Choose what to watch."
39
+ if r.get("watch") != "anywhere" and not r.get("target_id"):
40
+ return "Pick a user or group to watch."
41
+ if r.get("trigger") not in TRIGGER_LABELS:
42
+ return "Choose a trigger."
43
+ if r.get("trigger") in ("keywords", "regex") and not (r.get("pattern") or "").strip():
44
+ return "Enter keywords or a pattern."
45
+ if r.get("trigger") == "regex" and _regex(r["pattern"]) is None:
46
+ return "The regular expression is not valid."
47
+ return None
48
+
49
+
50
+ def rule_watches(rule, sender_id, chat_id) -> bool:
51
+ if not rule["enabled"]:
52
+ return False
53
+ if rule["watch"] == "anywhere":
54
+ return True
55
+ if rule["watch"] == "user":
56
+ return sender_id is not None and int(rule["target_id"]) == int(sender_id)
57
+ return chat_id is not None and int(rule["target_id"]) == int(chat_id)
58
+
59
+
60
+ def rule_matches(rule, rec, entities) -> bool:
61
+ text = rec.get("text") or ""
62
+ trig = rule["trigger"]
63
+ pattern = (rule.get("pattern") or "").strip()
64
+ if trig == "any":
65
+ return True
66
+ if trig == "keywords":
67
+ t = text.lower()
68
+ return any(k.strip().lower() in t for k in pattern.split(",") if k.strip())
69
+ if trig == "regex":
70
+ rx = _regex(pattern)
71
+ return bool(rx and rx.search(text))
72
+ if trig == "media":
73
+ return bool(rec.get("media"))
74
+ if trig == "entity":
75
+ wanted = {p.strip().lower() for p in pattern.split(",") if p.strip()}
76
+ types = {etype.lower() for _, etype in entities}
77
+ return bool(types & wanted) if wanted else bool(types)
78
+ return False
79
+
80
+
81
+ def describe(rec, rule) -> str:
82
+ who = rec.get("sender_name") or (f"@{rec['sender_username']}" if rec.get("sender_username") else str(rec.get("sender_id")))
83
+ where = rec.get("chat_title") or "a private chat"
84
+ if rule["trigger"] == "keywords":
85
+ t = (rec.get("text") or "").lower()
86
+ hit = next((k.strip() for k in rule["pattern"].split(",") if k.strip() and k.strip().lower() in t), "")
87
+ return f"“{hit}” mentioned by {who} in {where}"
88
+ if rule["trigger"] == "entity":
89
+ return f"Indicator posted by {who} in {where}"
90
+ if rule["trigger"] == "media":
91
+ return f"{who} shared media in {where}"
92
+ return f"{who} posted in {where}"
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # e-mail
97
+ # ---------------------------------------------------------------------------
98
+
99
+ def _send_mail_sync(smtp: dict, recipients, subject: str, text: str, html_body: str):
100
+ host, port = smtp.get("host"), int(smtp.get("port") or 0)
101
+ if not host or not port:
102
+ raise RuntimeError("SMTP host and port are not configured.")
103
+ if not recipients:
104
+ raise RuntimeError("No recipients configured.")
105
+ msg = EmailMessage()
106
+ msg["Subject"] = subject
107
+ msg["From"] = smtp.get("sender") or smtp.get("user") or "tgsherlock@localhost"
108
+ msg["To"] = ", ".join(recipients)
109
+ msg["Date"] = formatdate(localtime=True)
110
+ msg["Message-ID"] = make_msgid("tgsherlock")
111
+ msg.set_content(text)
112
+ msg.add_alternative(html_body, subtype="html")
113
+ ctx = ssl.create_default_context()
114
+ security = smtp.get("security", "starttls")
115
+ if security == "ssl":
116
+ server = smtplib.SMTP_SSL(host, port, timeout=20, context=ctx)
117
+ else:
118
+ server = smtplib.SMTP(host, port, timeout=20)
119
+ try:
120
+ server.ehlo()
121
+ if security == "starttls":
122
+ server.starttls(context=ctx)
123
+ server.ehlo()
124
+ if smtp.get("user"):
125
+ server.login(smtp["user"], smtp.get("password") or "")
126
+ server.send_message(msg)
127
+ finally:
128
+ try:
129
+ server.quit()
130
+ except Exception: # noqa: BLE001
131
+ pass
132
+
133
+
134
+ def split_recipients(value: str):
135
+ return [r.strip() for r in re.split(r"[,;\s]+", value or "") if "@" in r]
136
+
137
+
138
+ def _alert_html(alerts):
139
+ rows = []
140
+ colors = {"high": "#c0392b", "medium": "#b7791f", "low": "#2b6cb0"}
141
+ for a in alerts:
142
+ rows.append(
143
+ f"<tr><td style='padding:10px;border-bottom:1px solid #e5e7eb;vertical-align:top'>"
144
+ f"<span style='color:#fff;background:{colors.get(a['severity'], '#555')};border-radius:10px;"
145
+ f"padding:2px 8px;font-size:12px'>{html.escape(a['severity'].title())}</span></td>"
146
+ f"<td style='padding:10px;border-bottom:1px solid #e5e7eb'><b>{html.escape(a['title'])}</b><br>"
147
+ f"<span style='color:#374151'>{html.escape(a['text'] or '')}</span><br>"
148
+ f"<span style='color:#6b7280;font-size:12px'>{html.escape(a['date'])} · rule: "
149
+ f"{html.escape(a['rule_name'])}</span></td></tr>")
150
+ return ("<div style='font-family:Arial,sans-serif'><h2 style='margin:0 0 12px'>TGSherlock alert</h2>"
151
+ "<table style='border-collapse:collapse;width:100%'>" + "".join(rows) + "</table></div>")
152
+
153
+
154
+ def _alert_text(alerts):
155
+ parts = []
156
+ for a in alerts:
157
+ parts.append(f"[{a['severity'].upper()}] {a['title']}\n{a['text']}\n{a['date']} · rule: {a['rule_name']}\n")
158
+ return "\n".join(parts)
159
+
160
+
161
+ class Notifier:
162
+ """Delivers alerts by e-mail / webhook. E-mails are throttled per rule into digests."""
163
+
164
+ def __init__(self, settings):
165
+ self.settings = settings
166
+ self.last_mail = {} # rule_id -> monotonic timestamp
167
+ self.pending = {} # rule_id -> (rule, [alerts])
168
+ self._task = None
169
+ self._session = None
170
+
171
+ def start(self):
172
+ if self._task is None:
173
+ self._task = asyncio.ensure_future(self._flusher())
174
+
175
+ async def close(self):
176
+ if self._task:
177
+ self._task.cancel()
178
+ self._task = None
179
+ if self._session:
180
+ await self._session.close()
181
+ self._session = None
182
+
183
+ async def _http(self):
184
+ if self._session is None or self._session.closed:
185
+ self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15))
186
+ return self._session
187
+
188
+ async def deliver(self, rule, alert, on_status):
189
+ """on_status(label) is called with the delivery result for each channel."""
190
+ notify = rule.get("notify") or {}
191
+ if notify.get("webhook") and rule.get("webhook"):
192
+ asyncio.ensure_future(self._webhook(rule["webhook"], alert, on_status))
193
+ if notify.get("email"):
194
+ throttle = max(0, int(rule.get("throttle_min") or 0)) * 60
195
+ last = self.last_mail.get(rule["id"], 0)
196
+ if throttle and time.monotonic() - last < throttle:
197
+ self.pending.setdefault(rule["id"], (rule, []))[1].append(alert)
198
+ on_status("Email queued (digest)")
199
+ else:
200
+ self.last_mail[rule["id"]] = time.monotonic()
201
+ await self._email(rule, [alert], on_status)
202
+
203
+ async def _email(self, rule, alerts, on_status=None):
204
+ smtp = self.settings["smtp"]
205
+ recipients = split_recipients(rule.get("recipients") or smtp.get("to"))
206
+ sev = alerts[0]["severity"].upper()
207
+ subject = (f"[TGSherlock][{sev}] {alerts[0]['title']}" if len(alerts) == 1
208
+ else f"[TGSherlock] {len(alerts)} alerts · {rule['name']}")
209
+ loop = asyncio.get_running_loop()
210
+ try:
211
+ await loop.run_in_executor(None, _send_mail_sync, smtp, recipients, subject,
212
+ _alert_text(alerts), _alert_html(alerts))
213
+ if on_status:
214
+ on_status("Email sent")
215
+ except Exception as exc: # noqa: BLE001
216
+ log.warning("E-mail delivery failed: %s", exc)
217
+ if on_status:
218
+ on_status("Email failed")
219
+
220
+ async def _webhook(self, url, alert, on_status):
221
+ text = f"[{alert['severity'].upper()}] {alert['title']}\n{alert['text']}"
222
+ payload = {"text": text, "content": text[:1900], "alert": alert}
223
+ try:
224
+ session = await self._http()
225
+ async with session.post(url, json=payload) as resp:
226
+ on_status("Webhook sent" if resp.status < 400 else f"Webhook failed ({resp.status})")
227
+ except Exception as exc: # noqa: BLE001
228
+ log.warning("Webhook delivery failed: %s", exc)
229
+ on_status("Webhook failed")
230
+
231
+ async def _flusher(self):
232
+ while True:
233
+ await asyncio.sleep(30)
234
+ for rule_id in list(self.pending):
235
+ rule, alerts = self.pending[rule_id]
236
+ throttle = max(1, int(rule.get("throttle_min") or 0)) * 60
237
+ if time.monotonic() - self.last_mail.get(rule_id, 0) >= throttle and alerts:
238
+ del self.pending[rule_id]
239
+ self.last_mail[rule_id] = time.monotonic()
240
+ await self._email(rule, alerts)
241
+
242
+ async def send_test(self, to=None):
243
+ smtp = self.settings["smtp"]
244
+ recipients = split_recipients(to or smtp.get("to"))
245
+ loop = asyncio.get_running_loop()
246
+ sample = [{"severity": "low", "title": "Test alert", "text": "Your SMTP settings work.",
247
+ "date": time.strftime("%Y-%m-%d %H:%M:%S"), "rule_name": "Test"}]
248
+ await loop.run_in_executor(None, _send_mail_sync, smtp, recipients, "[TGSherlock] Test email",
249
+ _alert_text(sample), _alert_html(sample))
250
+ return recipients
@@ -0,0 +1,57 @@
1
+ """Risk classification and indicator (entity) extraction."""
2
+ import re
3
+
4
+ ENTITY_PATTERNS = [
5
+ ("Invite link", re.compile(r"(?:https?://)?(?:t\.me|telegram\.me)/(?:\+|joinchat/)[A-Za-z0-9_-]{8,}")),
6
+ ("URL", re.compile(r"https?://[^\s<>\"'()\[\]]+")),
7
+ ("Email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
8
+ ("IP", re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b")),
9
+ ("Crypto wallet", re.compile(r"\b(?:bc1[ac-hj-np-z02-9]{25,71}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}"
10
+ r"|0x[a-fA-F0-9]{40}|T[1-9A-HJ-NP-Za-km-z]{33})\b")),
11
+ ("Phone", re.compile(r"(?<![\w+])\+\d[\d \-().]{7,16}\d")),
12
+ ("Username", re.compile(r"(?<![\w@./])@[A-Za-z][A-Za-z0-9_]{4,31}\b")),
13
+ ("Hashtag", re.compile(r"(?<![\w&/#])#[^\s#.,!?;:()\[\]{}\"']{2,40}")),
14
+ ]
15
+
16
+ ENTITY_TYPES = [name for name, _ in ENTITY_PATTERNS]
17
+
18
+
19
+ def extract_entities(text: str):
20
+ """Return a list of (value, type) pairs found in text, de-duplicated."""
21
+ if not text:
22
+ return []
23
+ found, seen = [], set()
24
+ taken = [] # spans already claimed by a more specific type
25
+ for etype, rx in ENTITY_PATTERNS:
26
+ for m in rx.finditer(text):
27
+ if any(s <= m.start() < e for s, e in taken):
28
+ continue
29
+ value = m.group(0).rstrip(".,;:")
30
+ if etype == "Phone" and len(re.sub(r"\D", "", value)) < 9:
31
+ continue
32
+ key = (value.lower(), etype)
33
+ if key in seen:
34
+ continue
35
+ seen.add(key)
36
+ taken.append((m.start(), m.end()))
37
+ found.append((value, etype))
38
+ return found
39
+
40
+
41
+ def _contains(text_l: str, word: str) -> bool:
42
+ word = word.strip().lower()
43
+ return bool(word) and word in text_l
44
+
45
+
46
+ def classify(text: str, risk_cfg: dict):
47
+ """Return (level, matched_keyword). level: high | watch | clean."""
48
+ if not text:
49
+ return "clean", None
50
+ t = text.lower()
51
+ for w in risk_cfg.get("high", []):
52
+ if _contains(t, w):
53
+ return "high", w
54
+ for w in risk_cfg.get("watch", []):
55
+ if _contains(t, w):
56
+ return "watch", w
57
+ return "clean", None
@@ -0,0 +1,24 @@
1
+ """In-process event bus feeding Server-Sent Events to the browser."""
2
+ import asyncio
3
+ import json
4
+
5
+
6
+ class Bus:
7
+ def __init__(self):
8
+ self.queues = set()
9
+
10
+ def subscribe(self):
11
+ q = asyncio.Queue(maxsize=500)
12
+ self.queues.add(q)
13
+ return q
14
+
15
+ def unsubscribe(self, q):
16
+ self.queues.discard(q)
17
+
18
+ def emit(self, kind, data=None):
19
+ payload = json.dumps({"type": kind, "data": data}, ensure_ascii=False, default=str)
20
+ for q in list(self.queues):
21
+ try:
22
+ q.put_nowait(payload)
23
+ except asyncio.QueueFull:
24
+ pass
@@ -0,0 +1,117 @@
1
+ """Command line entry point: `tgsherlock`."""
2
+ import argparse
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import signal
7
+ import sys
8
+ import threading
9
+ import urllib.request
10
+ import webbrowser
11
+
12
+ from aiohttp import web
13
+
14
+ from . import __version__, desktop
15
+ from .bus import Bus
16
+ from .config import Settings
17
+ from .server import create_app
18
+ from .tg import TG
19
+
20
+
21
+ def _running_instance(port) -> bool:
22
+ try:
23
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/ping", timeout=1.5) as resp:
24
+ return json.loads(resp.read().decode()).get("app") == "tgsherlock"
25
+ except Exception: # noqa: BLE001
26
+ return False
27
+
28
+
29
+ def _open_browser(url):
30
+ threading.Thread(target=webbrowser.open, args=(url,), daemon=True).start()
31
+
32
+
33
+ async def _serve(settings, host, port, open_browser):
34
+ bus = Bus()
35
+ tgm = TG(settings, bus)
36
+ stop = asyncio.Event()
37
+ app = create_app(settings, tgm, bus, stop, desktop.status, local_only=host in ("127.0.0.1", "localhost"))
38
+ runner = web.AppRunner(app, access_log=None, shutdown_timeout=2)
39
+ await runner.setup()
40
+ site = web.TCPSite(runner, host, port)
41
+ try:
42
+ await site.start()
43
+ except OSError as exc:
44
+ print(f"Cannot listen on {host}:{port} ({exc.strerror}). Try: tgsherlock --port 8790", file=sys.stderr)
45
+ await runner.cleanup()
46
+ return 1
47
+ url = f"http://{'127.0.0.1' if host in ('0.0.0.0', '::') else host}:{port}/"
48
+ print(f"TGSherlock {__version__} running at {url} (Ctrl+C to stop)")
49
+ loop = asyncio.get_running_loop()
50
+ for sig in (signal.SIGINT, signal.SIGTERM):
51
+ try:
52
+ loop.add_signal_handler(sig, stop.set)
53
+ except (NotImplementedError, RuntimeError):
54
+ pass
55
+ asyncio.ensure_future(tgm.start())
56
+ if open_browser:
57
+ _open_browser(url)
58
+ await stop.wait()
59
+ print("Stopping…")
60
+ await tgm.close()
61
+ await runner.cleanup()
62
+ return 0
63
+
64
+
65
+ def main(argv=None):
66
+ parser = argparse.ArgumentParser(prog="tgsherlock", description="Telegram OSINT web panel (runs locally).")
67
+ parser.add_argument("--host", default="127.0.0.1", help="interface to bind (default 127.0.0.1)")
68
+ parser.add_argument("--port", type=int, help="port (default 8765 or the one saved in Settings)")
69
+ parser.add_argument("--no-browser", action="store_true", help="do not open the browser")
70
+ parser.add_argument("--install-desktop", action="store_true", help="add TGSherlock to the application menu")
71
+ parser.add_argument("--uninstall-desktop", action="store_true", help="remove the application-menu entry")
72
+ parser.add_argument("--debug", action="store_true", help="verbose logging")
73
+ parser.add_argument("--version", action="version", version=f"tgsherlock {__version__}")
74
+ args = parser.parse_args(argv)
75
+
76
+ logging.basicConfig(level=logging.DEBUG if args.debug else logging.WARNING,
77
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s")
78
+
79
+ if args.uninstall_desktop:
80
+ desktop.uninstall()
81
+ print("Removed the application-menu entry.")
82
+ return
83
+ if args.install_desktop:
84
+ path = desktop.install()
85
+ print(f"Installed menu entry: {path}")
86
+ return
87
+
88
+ settings = Settings()
89
+ port = args.port or int(settings["port"])
90
+ open_browser = not args.no_browser and settings["prefs"].get("browser", True)
91
+
92
+ if _running_instance(port):
93
+ url = f"http://127.0.0.1:{port}/"
94
+ print(f"TGSherlock is already running at {url}")
95
+ if not args.no_browser:
96
+ webbrowser.open(url)
97
+ return
98
+
99
+ if desktop.supported() and not settings["desktop_installed"]:
100
+ try:
101
+ desktop.install()
102
+ settings.set("desktop_installed", True)
103
+ except OSError:
104
+ pass
105
+
106
+ if args.host not in ("127.0.0.1", "localhost"):
107
+ print("Warning: the panel has no password. Only bind to other interfaces on trusted networks.",
108
+ file=sys.stderr)
109
+ try:
110
+ code = asyncio.run(_serve(settings, args.host, port, open_browser))
111
+ except KeyboardInterrupt:
112
+ code = 0
113
+ sys.exit(code)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()