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.
- tgsherlock-1.0.0/LICENSE +22 -0
- tgsherlock-1.0.0/PKG-INFO +67 -0
- tgsherlock-1.0.0/README.md +48 -0
- tgsherlock-1.0.0/pyproject.toml +33 -0
- tgsherlock-1.0.0/setup.cfg +4 -0
- tgsherlock-1.0.0/src/tgsherlock/__init__.py +2 -0
- tgsherlock-1.0.0/src/tgsherlock/__main__.py +4 -0
- tgsherlock-1.0.0/src/tgsherlock/alerts.py +250 -0
- tgsherlock-1.0.0/src/tgsherlock/analysis.py +57 -0
- tgsherlock-1.0.0/src/tgsherlock/assets/tgsherlock.png +0 -0
- tgsherlock-1.0.0/src/tgsherlock/bus.py +24 -0
- tgsherlock-1.0.0/src/tgsherlock/cli.py +117 -0
- tgsherlock-1.0.0/src/tgsherlock/config.py +97 -0
- tgsherlock-1.0.0/src/tgsherlock/db.py +209 -0
- tgsherlock-1.0.0/src/tgsherlock/desktop.py +75 -0
- tgsherlock-1.0.0/src/tgsherlock/paths.py +40 -0
- tgsherlock-1.0.0/src/tgsherlock/server.py +565 -0
- tgsherlock-1.0.0/src/tgsherlock/static/app.css +349 -0
- tgsherlock-1.0.0/src/tgsherlock/static/app.js +1489 -0
- tgsherlock-1.0.0/src/tgsherlock/static/favicon.png +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/ChakraPetch-600-latin-ext.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/ChakraPetch-600-latin.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/ChakraPetch-700-latin-ext.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/ChakraPetch-700-latin.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexMono-400-latin.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexMono-500-latin.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-400-latin-ext.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-400-latin.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-500-latin-ext.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-500-latin.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-600-latin-ext.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-600-latin.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-700-latin-ext.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts/IBMPlexSans-700-latin.woff2 +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/fonts.css +16 -0
- tgsherlock-1.0.0/src/tgsherlock/static/index.html +17 -0
- tgsherlock-1.0.0/src/tgsherlock/static/logo-sm.png +0 -0
- tgsherlock-1.0.0/src/tgsherlock/static/logo.png +0 -0
- tgsherlock-1.0.0/src/tgsherlock/tg.py +1087 -0
- tgsherlock-1.0.0/src/tgsherlock.egg-info/PKG-INFO +67 -0
- tgsherlock-1.0.0/src/tgsherlock.egg-info/SOURCES.txt +45 -0
- tgsherlock-1.0.0/src/tgsherlock.egg-info/dependency_links.txt +1 -0
- tgsherlock-1.0.0/src/tgsherlock.egg-info/entry_points.txt +2 -0
- tgsherlock-1.0.0/src/tgsherlock.egg-info/requires.txt +3 -0
- tgsherlock-1.0.0/src/tgsherlock.egg-info/top_level.txt +1 -0
tgsherlock-1.0.0/LICENSE
ADDED
|
@@ -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
|
+

|
|
27
|
+

|
|
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
|
+

|
|
8
|
+

|
|
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,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
|
|
Binary file
|
|
@@ -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()
|