first commit
This commit is contained in:
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir requests python-dateutil
|
||||
|
||||
# Copy the script
|
||||
COPY nvd_telegram_notifier.py .
|
||||
|
||||
# Create directories for persistent data
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Run the script
|
||||
CMD ["python", "-u", "nvd_telegram_notifier.py"]
|
||||
3
data/state.json
Normal file
3
data/state.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"last_pub": "2026-01-09T10:23:51.000Z"
|
||||
}
|
||||
20
data/subscribers.json
Normal file
20
data/subscribers.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"offset": 844171944,
|
||||
"users": {
|
||||
"145645559": {
|
||||
"chat_id": 145645559,
|
||||
"is_bot": false,
|
||||
"first_name": "Tomas",
|
||||
"last_name": "Mali",
|
||||
"username": "tomasmali",
|
||||
"language_code": "en",
|
||||
"subscribed_at": "2026-01-09T10:22:10.723523+00:00",
|
||||
"last_seen": "2026-01-09T12:48:47.988471+00:00",
|
||||
"start_payload": "severity=high",
|
||||
"preferences": {
|
||||
"severity": "high"
|
||||
},
|
||||
"last_sent": "2026-01-09T12:15:53.420"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
nvd-notifier:
|
||||
build: .
|
||||
container_name: nvd-telegram-notifier
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# Persist state files
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
# Override paths to use the data volume
|
||||
- STATE_PATH=/app/data/state.json
|
||||
- SUBSCRIBERS_PATH=/app/data/subscribers.json
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 8.8.4.4
|
||||
# Uncomment to see logs in real-time
|
||||
# logging:
|
||||
# driver: "json-file"
|
||||
# options:
|
||||
# max-size: "10m"
|
||||
# max-file: "3"
|
||||
827
nvd_telegram_notifier.py
Normal file
827
nvd_telegram_notifier.py
Normal file
@@ -0,0 +1,827 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
nvd_telegram_notifier_backfill.py - IMPROVED VERSION
|
||||
|
||||
Features:
|
||||
- Default severity=medium filter for new subscribers
|
||||
- Improved welcome message with filter examples
|
||||
- Backfill supports HOURS: /backfill 1h = last 1 hour, /backfill 3h = last 3 hours
|
||||
- Backfill limited to 15 CVEs max (prevents spam)
|
||||
- New subscribers only get FUTURE CVEs
|
||||
- /reset to skip old CVEs
|
||||
|
||||
Environment variables:
|
||||
- TELEGRAM_BOT_TOKEN (required)
|
||||
- NVD_API_KEY (optional)
|
||||
- POLL_MINUTES (default 15)
|
||||
- STATE_PATH (default ./state.json)
|
||||
- SUBSCRIBERS_PATH (default ./subscribers.json)
|
||||
- TG_POLL_SECONDS (default 15)
|
||||
- LOG_STDOUT (0 or 1)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from dateutil import parser as dateparser
|
||||
from html import escape
|
||||
|
||||
# ---------------- config ----------------
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||
NVD_API_KEY = os.environ.get("NVD_API_KEY")
|
||||
POLL_MINUTES = int(os.environ.get("POLL_MINUTES", "15"))
|
||||
STATE_PATH = os.environ.get("STATE_PATH", "./state.json")
|
||||
SUBSCRIBERS_PATH = os.environ.get("SUBSCRIBERS_PATH", "./subscribers.json")
|
||||
TG_POLL_SECONDS = int(os.environ.get("TG_POLL_SECONDS", "15"))
|
||||
LOG_STDOUT = os.environ.get("LOG_STDOUT", "0") == "1"
|
||||
|
||||
# Quiet hours configuration (24-hour format)
|
||||
QUIET_HOURS_START = int(os.environ.get("QUIET_HOURS_START", "22")) # 10 PM
|
||||
QUIET_HOURS_END = int(os.environ.get("QUIET_HOURS_END", "8")) # 8 AM
|
||||
QUIET_HOURS_ENABLED = os.environ.get("QUIET_HOURS_ENABLED", "1") == "1"
|
||||
|
||||
NVD_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0/"
|
||||
PER_PAGE = 2000
|
||||
MAX_MSG_LEN = 3800
|
||||
TELEGRAM_API = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}" if TELEGRAM_BOT_TOKEN else None
|
||||
|
||||
|
||||
# ---------------- utilities ----------------
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
|
||||
|
||||
def is_quiet_hours():
|
||||
"""Check if current time is within quiet hours"""
|
||||
if not QUIET_HOURS_ENABLED:
|
||||
return False
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current_hour = now.hour
|
||||
|
||||
# Handle case where quiet hours span midnight
|
||||
if QUIET_HOURS_START > QUIET_HOURS_END:
|
||||
# e.g., 22:00 to 08:00
|
||||
return current_hour >= QUIET_HOURS_START or current_hour < QUIET_HOURS_END
|
||||
else:
|
||||
# e.g., 01:00 to 06:00
|
||||
return QUIET_HOURS_START <= current_hour < QUIET_HOURS_END
|
||||
|
||||
|
||||
def safe_write_json(path, data):
|
||||
try:
|
||||
d = os.path.dirname(path)
|
||||
if d:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print("Failed to write json:", path, e)
|
||||
|
||||
|
||||
def safe_load_json(path, default):
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
safe_write_json(path, default)
|
||||
return default
|
||||
|
||||
|
||||
# ---------------- state ----------------
|
||||
def load_state():
|
||||
default = {"last_pub": (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S.000Z")}
|
||||
return safe_load_json(STATE_PATH, default)
|
||||
|
||||
|
||||
def save_state(state):
|
||||
safe_write_json(STATE_PATH, state)
|
||||
|
||||
|
||||
# ---------------- subscribers ----------------
|
||||
def load_subscribers():
|
||||
default = {"offset": 0, "users": {}}
|
||||
return safe_load_json(SUBSCRIBERS_PATH, default)
|
||||
|
||||
|
||||
def save_subscribers(data):
|
||||
safe_write_json(SUBSCRIBERS_PATH, data)
|
||||
|
||||
|
||||
def parse_start_payload(payload_text):
|
||||
prefs = {}
|
||||
if not payload_text:
|
||||
# Default to medium severity and comprehensive keywords if no preferences provided
|
||||
prefs["severity"] = "high"
|
||||
prefs["keywords"] = [
|
||||
"linux", "kernel", "ubuntu", "debian", "rhel",
|
||||
"postgresql", "postgres", "mysql", "mariadb", "redis", "mongodb", "elasticsearch",
|
||||
"nginx", "apache", "haproxy",
|
||||
"tomcat", "java", "jdk", "openjdk", "spring", "hibernate", "log4j", "struts",
|
||||
"docker", "kubernetes", "k8s", "helm",
|
||||
"aws", "ec2", "rds", "eks", "lambda", "s3", "iam",
|
||||
"angular", "node", "nodejs",
|
||||
"ibm", "iseries", "db2", "websphere",
|
||||
"openssl", "ssh", "openssh", "sudo",
|
||||
"bind", "dns", "openvpn", "vpn", "iptables", "firewalld",
|
||||
"jenkins", "gitlab", "terraform", "ansible",
|
||||
"prometheus", "grafana", "vault", "consul"
|
||||
]
|
||||
return prefs
|
||||
tokens = [t.strip() for t in payload_text.split(",") if t.strip()]
|
||||
keywords = []
|
||||
severity_set = False
|
||||
for t in tokens:
|
||||
if t.lower().startswith("severity="):
|
||||
val = t.split("=", 1)[1].strip().lower()
|
||||
if val in ("low", "medium", "high", "critical"):
|
||||
prefs["severity"] = val
|
||||
severity_set = True
|
||||
else:
|
||||
keywords.append(t.lower())
|
||||
# If no severity specified, default to high
|
||||
if not severity_set:
|
||||
prefs["severity"] = "high"
|
||||
if keywords:
|
||||
prefs["keywords"] = keywords
|
||||
return prefs
|
||||
|
||||
|
||||
def extract_user_info_from_msg(msg):
|
||||
user = msg.get("from", {}) or {}
|
||||
chat = msg.get("chat", {}) or {}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
info = {
|
||||
"chat_id": chat.get("id"),
|
||||
"is_bot": user.get("is_bot"),
|
||||
"first_name": user.get("first_name"),
|
||||
"last_name": user.get("last_name"),
|
||||
"username": user.get("username"),
|
||||
"language_code": user.get("language_code"),
|
||||
"subscribed_at": now,
|
||||
"last_seen": now,
|
||||
"start_payload": None,
|
||||
"preferences": {},
|
||||
"last_sent": None
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def build_welcome_message(prefs):
|
||||
"""Build a nice welcome message showing active filters"""
|
||||
msg = "✅ <b>Subscribed to CVE notifications!</b>\n\n"
|
||||
msg += "📬 You will receive NEW CVEs as they're published.\n\n"
|
||||
|
||||
msg += "🔧 <b>Your Current Filters:</b>\n"
|
||||
severity = prefs.get("severity", "medium")
|
||||
msg += f"• Severity: <code>{severity.upper()}</code> and above\n"
|
||||
keywords = prefs.get("keywords", [])
|
||||
if keywords:
|
||||
msg += f"• Keywords: <code>{', '.join(keywords)}</code>\n"
|
||||
else:
|
||||
msg += "• Keywords: <i>none (all CVEs)</i>\n"
|
||||
|
||||
msg += "\n━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
msg += "📋 <b>Available Commands:</b>\n\n"
|
||||
msg += "<b>Backfill:</b>\n"
|
||||
msg += "• <code>/backfill 1h</code> - Last 1 hour\n"
|
||||
msg += "• <code>/backfill 6h</code> - Last 6 hours\n"
|
||||
msg += "• <code>/backfill 1d</code> - Last 1 day (max 15)\n\n"
|
||||
msg += "<b>Management:</b>\n"
|
||||
msg += "• <code>/status</code> - Check your settings\n"
|
||||
msg += "• <code>/reset</code> - Reset timeline\n"
|
||||
msg += "• <code>/stop</code> - Unsubscribe\n\n"
|
||||
msg += "━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
msg += "🎯 <b>Filter Examples:</b>\n\n"
|
||||
msg += "<b>By severity only:</b>\n"
|
||||
msg += "• <code>/start severity=high</code>\n"
|
||||
msg += "• <code>/start severity=critical</code>\n\n"
|
||||
msg += "<b>By keywords only:</b>\n"
|
||||
msg += "• <code>/start linux,nginx</code>\n"
|
||||
msg += "• <code>/start apache,tomcat</code>\n\n"
|
||||
msg += "<b>Combined filters:</b>\n"
|
||||
msg += "• <code>/start severity=high,postgresql</code>\n"
|
||||
msg += "• <code>/start severity=critical,linux,kernel</code>\n\n"
|
||||
msg += "💡 <i>Tip: Without filters, default is severity=medium</i>"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def add_or_update_user_from_msg(msg, payload=None):
|
||||
info = extract_user_info_from_msg(msg)
|
||||
prefs = parse_start_payload(payload)
|
||||
info["start_payload"] = payload
|
||||
info["preferences"] = prefs
|
||||
|
||||
subs = load_subscribers()
|
||||
users = subs.get("users", {})
|
||||
cid = str(info["chat_id"])
|
||||
|
||||
if cid not in users:
|
||||
# New subscribers get last_sent = NOW
|
||||
info["last_sent"] = now_iso()
|
||||
users[cid] = info
|
||||
subs["users"] = users
|
||||
save_subscribers(subs)
|
||||
print(f"Added new subscriber: {cid} with last_sent={info['last_sent']}, filters={prefs}")
|
||||
|
||||
# Send welcome message
|
||||
send_telegram_to_chat(info["chat_id"], build_welcome_message(prefs))
|
||||
|
||||
# Auto-backfill last 12 hours for new subscribers
|
||||
send_telegram_to_chat(
|
||||
info["chat_id"],
|
||||
"🔍 <b>Loading recent CVEs...</b>\n\n"
|
||||
"Fetching CVEs from the last 12 hours to get you started.\n"
|
||||
"(This is a one-time backfill)"
|
||||
)
|
||||
sent = backfill_to_user(info["chat_id"],
|
||||
since_iso=(datetime.now(timezone.utc) - timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
||||
max_items=15)
|
||||
send_telegram_to_chat(info["chat_id"], f"✅ Backfill complete: {sent} CVE(s) sent.\n\nYou'll now receive new CVEs as they're published!")
|
||||
else:
|
||||
users[cid]["last_seen"] = datetime.now(timezone.utc).isoformat()
|
||||
if payload:
|
||||
users[cid]["start_payload"] = payload
|
||||
users[cid]["preferences"] = prefs
|
||||
subs["users"] = users
|
||||
save_subscribers(subs)
|
||||
print("Updated subscriber:", cid, "filters=", prefs)
|
||||
|
||||
# Show updated filters
|
||||
keywords_str = ', '.join(prefs.get('keywords', []))[:100] # Truncate if too long
|
||||
if len(keywords_str) >= 100:
|
||||
keywords_str += "..."
|
||||
|
||||
send_telegram_to_chat(
|
||||
info["chat_id"],
|
||||
f"✅ <b>Welcome back!</b>\n\n"
|
||||
f"Your filters have been updated:\n"
|
||||
f"• Severity: <code>{prefs.get('severity', 'high').upper()}</code>\n"
|
||||
f"• Keywords: <code>{keywords_str or 'none'}</code>"
|
||||
)
|
||||
|
||||
|
||||
def remove_user_by_chat(chat_id):
|
||||
subs = load_subscribers()
|
||||
users = subs.get("users", {})
|
||||
cid = str(chat_id)
|
||||
if cid in users:
|
||||
users.pop(cid)
|
||||
subs["users"] = users
|
||||
save_subscribers(subs)
|
||||
print("Removed subscriber:", cid)
|
||||
send_telegram_to_chat(chat_id, "✅ Unsubscribed from CVE notifications.")
|
||||
return True
|
||||
else:
|
||||
send_telegram_to_chat(chat_id, "ℹ️ You were not subscribed.")
|
||||
return False
|
||||
|
||||
|
||||
def reset_user_timeline(chat_id):
|
||||
subs = load_subscribers()
|
||||
users = subs.get("users", {})
|
||||
cid = str(chat_id)
|
||||
|
||||
if cid not in users:
|
||||
send_telegram_to_chat(chat_id, "❌ Not subscribed. Use /start first.")
|
||||
return
|
||||
|
||||
users[cid]["last_sent"] = now_iso()
|
||||
users[cid]["last_seen"] = datetime.now(timezone.utc).isoformat()
|
||||
subs["users"] = users
|
||||
save_subscribers(subs)
|
||||
|
||||
send_telegram_to_chat(
|
||||
chat_id,
|
||||
f"✅ <b>Timeline reset!</b>\n\n"
|
||||
f"You'll only receive CVEs published after:\n"
|
||||
f"<code>{users[cid]['last_sent']}</code>"
|
||||
)
|
||||
|
||||
|
||||
def get_user_status(chat_id):
|
||||
subs = load_subscribers()
|
||||
users = subs.get("users", {})
|
||||
cid = str(chat_id)
|
||||
|
||||
if cid not in users:
|
||||
send_telegram_to_chat(chat_id, "❌ Not subscribed. Use /start to subscribe.")
|
||||
return
|
||||
|
||||
user = users[cid]
|
||||
status_msg = f"📊 <b>Subscription Status</b>\n\n"
|
||||
status_msg += f"✅ Subscribed: <code>{user.get('subscribed_at', 'Unknown')[:19]}</code>\n"
|
||||
status_msg += f"📅 Tracking CVEs after: <code>{user.get('last_sent', 'Unknown')[:19]}</code>\n\n"
|
||||
|
||||
prefs = user.get("preferences", {})
|
||||
status_msg += f"🔧 <b>Active Filters:</b>\n"
|
||||
severity = prefs.get("severity", "medium")
|
||||
status_msg += f"• Severity: <code>{severity.upper()}</code> and above\n"
|
||||
keywords = prefs.get("keywords", [])
|
||||
if keywords:
|
||||
status_msg += f"• Keywords: <code>{', '.join(keywords)}</code>\n"
|
||||
else:
|
||||
status_msg += "• Keywords: <i>none (all CVEs)</i>\n"
|
||||
|
||||
status_msg += "\n💡 <i>Use /start with new filters to update</i>"
|
||||
|
||||
send_telegram_to_chat(chat_id, status_msg)
|
||||
|
||||
|
||||
def show_help(chat_id):
|
||||
subs = load_subscribers()
|
||||
users = subs.get("users", {})
|
||||
cid = str(chat_id)
|
||||
|
||||
if cid not in users:
|
||||
send_telegram_to_chat(chat_id, "❌ Not subscribed. Use /start to subscribe first.")
|
||||
return
|
||||
|
||||
user = users[cid]
|
||||
prefs = user.get("preferences", {})
|
||||
|
||||
help_msg = "📬 <b>You will receive NEW CVEs as they're published.</b>\n\n"
|
||||
help_msg += "🔧 <b>Your Current Filters:</b>\n"
|
||||
severity = prefs.get("severity", "medium")
|
||||
help_msg += f"• Severity: <code>{severity.upper()}</code> and above\n"
|
||||
keywords = prefs.get("keywords", [])
|
||||
if keywords:
|
||||
help_msg += f"• Keywords: <code>{', '.join(keywords)}</code>\n"
|
||||
else:
|
||||
help_msg += "• Keywords: <i>none (all CVEs)</i>\n"
|
||||
|
||||
help_msg += "\n━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
help_msg += "📋 <b>Available Commands:</b>\n\n"
|
||||
help_msg += "<b>Backfill:</b>\n"
|
||||
help_msg += "• <code>/backfill 1h</code> - Last 1 hour\n"
|
||||
help_msg += "• <code>/backfill 6h</code> - Last 6 hours\n"
|
||||
help_msg += "• <code>/backfill 1d</code> - Last 1 day (max 15)\n\n"
|
||||
help_msg += "<b>Management:</b>\n"
|
||||
help_msg += "• <code>/status</code> - Check your settings\n"
|
||||
help_msg += "• <code>/reset</code> - Reset timeline\n"
|
||||
help_msg += "• <code>/help</code> - Show this message\n"
|
||||
help_msg += "• <code>/stop</code> - Unsubscribe\n\n"
|
||||
help_msg += "━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
help_msg += "🎯 <b>Filter Examples:</b>\n\n"
|
||||
help_msg += "<b>By severity only:</b>\n"
|
||||
help_msg += "• <code>/start severity=high</code>\n"
|
||||
help_msg += "• <code>/start severity=critical</code>\n\n"
|
||||
help_msg += "<b>By keywords only:</b>\n"
|
||||
help_msg += "• <code>/start linux,nginx</code>\n"
|
||||
help_msg += "• <code>/start apache,tomcat</code>\n\n"
|
||||
help_msg += "<b>Combined filters:</b>\n"
|
||||
help_msg += "• <code>/start severity=high,postgresql</code>\n"
|
||||
help_msg += "• <code>/start severity=critical,linux,kernel</code>\n\n"
|
||||
help_msg += "💡 <i>Tip: Without filters, default is severity=medium</i>"
|
||||
|
||||
send_telegram_to_chat(chat_id, help_msg)
|
||||
|
||||
|
||||
def parse_backfill_timespec(timespec):
|
||||
"""Parse time specs like '1h', '6h', '1d' into hours"""
|
||||
timespec = timespec.strip().lower()
|
||||
|
||||
# Default to 1 hour if just a number
|
||||
if timespec.isdigit():
|
||||
return int(timespec)
|
||||
|
||||
# Parse format like "1h", "6h", "1d"
|
||||
import re
|
||||
match = re.match(r'^(\d+)([hd])$', timespec)
|
||||
if not match:
|
||||
return 1 # default to 1 hour
|
||||
|
||||
num = int(match.group(1))
|
||||
unit = match.group(2)
|
||||
|
||||
if unit == 'h':
|
||||
return num
|
||||
elif unit == 'd':
|
||||
return num * 24
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------- Telegram polling ----------------
|
||||
def poll_telegram_updates_once():
|
||||
if not TELEGRAM_API:
|
||||
return
|
||||
subs = load_subscribers()
|
||||
offset = subs.get("offset", 0) or 0
|
||||
url = f"{TELEGRAM_API}/getUpdates"
|
||||
params = {"timeout": 0, "offset": offset, "limit": 100}
|
||||
try:
|
||||
r = requests.get(url, params=params, timeout=20)
|
||||
r.raise_for_status()
|
||||
res = r.json()
|
||||
if not res.get("ok"):
|
||||
print("getUpdates not ok:", res)
|
||||
return
|
||||
updates = res.get("result", []) or []
|
||||
max_uid = offset
|
||||
for u in updates:
|
||||
uid = u.get("update_id")
|
||||
if uid is not None and uid >= max_uid:
|
||||
max_uid = uid + 1
|
||||
msg = u.get("message") or u.get("edited_message") or u.get("channel_post") or {}
|
||||
if not msg:
|
||||
continue
|
||||
text = (msg.get("text") or "").strip()
|
||||
chat = msg.get("chat", {}) or {}
|
||||
chat_id = chat.get("id")
|
||||
|
||||
if text.startswith("/start"):
|
||||
parts = text.split(" ", 1)
|
||||
payload = parts[1] if len(parts) > 1 else None
|
||||
add_or_update_user_from_msg(msg, payload)
|
||||
elif text.startswith("/stop"):
|
||||
if chat_id:
|
||||
remove_user_by_chat(chat_id)
|
||||
elif text.startswith("/reset"):
|
||||
if chat_id:
|
||||
reset_user_timeline(chat_id)
|
||||
elif text.startswith("/status"):
|
||||
if chat_id:
|
||||
get_user_status(chat_id)
|
||||
elif text.startswith("/help"):
|
||||
if chat_id:
|
||||
show_help(chat_id)
|
||||
elif text.startswith("/backfill"):
|
||||
parts = text.split(" ", 1)
|
||||
timespec = "1h" # default to 1 hour
|
||||
if len(parts) > 1:
|
||||
timespec = parts[1].strip()
|
||||
|
||||
hours = parse_backfill_timespec(timespec)
|
||||
hours = min(hours, 72) # cap at 3 days
|
||||
|
||||
if chat_id:
|
||||
send_telegram_to_chat(
|
||||
chat_id,
|
||||
f"🔍 Searching for CVEs from last {hours} hour(s)...\n"
|
||||
f"(Max 15 CVEs, won't affect your regular notifications)"
|
||||
)
|
||||
since = (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
sent = backfill_to_user(chat_id, since_iso=since, max_items=15)
|
||||
send_telegram_to_chat(chat_id, f"✅ Backfill complete: {sent} CVE(s) sent.")
|
||||
else:
|
||||
# Update last_seen
|
||||
cid = str(chat_id)
|
||||
if cid:
|
||||
s = load_subscribers()
|
||||
users = s.get("users", {})
|
||||
if cid in users:
|
||||
users[cid]["last_seen"] = datetime.now(timezone.utc).isoformat()
|
||||
s["users"] = users
|
||||
save_subscribers(s)
|
||||
|
||||
subs2 = load_subscribers()
|
||||
subs2["offset"] = max_uid
|
||||
save_subscribers(subs2)
|
||||
except Exception as e:
|
||||
print("Failed to poll Telegram updates:", e)
|
||||
|
||||
|
||||
# ---------------- Telegram send ----------------
|
||||
def send_telegram_to_chat(chat_identifier, text):
|
||||
if not TELEGRAM_API:
|
||||
print("No TELEGRAM_BOT_TOKEN configured")
|
||||
return False
|
||||
payload = {"chat_id": chat_identifier, "text": text, "disable_web_page_preview": True, "parse_mode": "HTML"}
|
||||
try:
|
||||
r = requests.post(f"{TELEGRAM_API}/sendMessage", data=payload, timeout=20)
|
||||
if r.status_code != 200:
|
||||
try:
|
||||
j = r.json()
|
||||
desc = j.get("description", "")
|
||||
if r.status_code in (403, 400) and ("blocked" in desc.lower() or "not found" in desc.lower()):
|
||||
cid = int(chat_identifier)
|
||||
remove_user_by_chat(cid)
|
||||
except Exception:
|
||||
pass
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print("Failed to send telegram:", e)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------- NVD helpers ----------------
|
||||
def nvd_request(params):
|
||||
headers = {"Accept": "application/json"}
|
||||
if NVD_API_KEY:
|
||||
headers["apiKey"] = NVD_API_KEY
|
||||
r = requests.get(NVD_BASE, params=params, headers=headers, timeout=60)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def extract_cve_info(v):
|
||||
cve = v.get("cve") or {}
|
||||
cve_id = cve.get("id") or cve.get("CVE_data_meta", {}).get("ID") or v.get("cveId")
|
||||
published = cve.get("published") or v.get("published") or v.get("publishedDate")
|
||||
desc = ""
|
||||
for d in (cve.get("descriptions") or []):
|
||||
if d.get("value"):
|
||||
desc = d["value"]
|
||||
break
|
||||
if not desc:
|
||||
desc = cve.get("description") or ""
|
||||
refs = []
|
||||
for r in (cve.get("references") or []):
|
||||
url = r.get("url") or r.get("reference")
|
||||
if url:
|
||||
refs.append(url)
|
||||
cvss_scores = []
|
||||
metrics = cve.get("metrics") or {}
|
||||
for key in metrics:
|
||||
for item in metrics.get(key) or []:
|
||||
cvss = item.get("cvssData") or item.get("cvssMetric")
|
||||
if cvss:
|
||||
score = cvss.get("baseScore") or cvss.get("score")
|
||||
try:
|
||||
if score is not None:
|
||||
cvss_scores.append(float(score))
|
||||
except Exception:
|
||||
pass
|
||||
cvss_strs = [("{:.1f}".format(s)) for s in cvss_scores]
|
||||
return {"id": cve_id, "published": published, "description": desc, "cvss": cvss_scores, "cvss_str": cvss_strs, "references": refs}
|
||||
|
||||
|
||||
def query_new_cves(last_pub, now_iso):
|
||||
params = {"pubStartDate": last_pub, "pubEndDate": now_iso, "resultsPerPage": PER_PAGE, "startIndex": 0}
|
||||
all_cves = []
|
||||
while True:
|
||||
data = nvd_request(params)
|
||||
total = data.get("totalResults") or 0
|
||||
vulns = data.get("vulnerabilities") or []
|
||||
for v in vulns:
|
||||
info = extract_cve_info(v)
|
||||
if info.get("id"):
|
||||
all_cves.append(info)
|
||||
start = params.get("startIndex", 0)
|
||||
per = params.get("resultsPerPage", PER_PAGE)
|
||||
if not total or start + per >= int(total):
|
||||
break
|
||||
params["startIndex"] = start + per
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
all_cves.sort(key=lambda c: dateparser.parse(c.get("published") or now_iso))
|
||||
except Exception:
|
||||
pass
|
||||
return all_cves
|
||||
|
||||
|
||||
# ---------------- matching & formatting ----------------
|
||||
def cvss_to_severity(cvss_score):
|
||||
if cvss_score is None:
|
||||
return None
|
||||
try:
|
||||
s = float(cvss_score)
|
||||
except Exception:
|
||||
return None
|
||||
if s >= 9.0:
|
||||
return "critical"
|
||||
if s >= 7.0:
|
||||
return "high"
|
||||
if s >= 4.0:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def matches_user_preferences(cve, user):
|
||||
prefs = user.get("preferences", {}) or {}
|
||||
sev_pref = prefs.get("severity")
|
||||
if sev_pref:
|
||||
scores = cve.get("cvss") or []
|
||||
if not scores:
|
||||
return False
|
||||
meets = False
|
||||
for s in scores:
|
||||
severity = cvss_to_severity(s)
|
||||
if severity:
|
||||
order = {"low": 1, "medium": 2, "high": 3, "critical": 4}
|
||||
if order.get(severity, 0) >= order.get(sev_pref, 0):
|
||||
meets = True
|
||||
break
|
||||
if not meets:
|
||||
return False
|
||||
keywords = prefs.get("keywords") or []
|
||||
if keywords:
|
||||
text = (cve.get("description", "") + " " + (cve.get("id") or "")).lower()
|
||||
if not any(kw.lower() in text for kw in keywords):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def extract_subject(description):
|
||||
if not description:
|
||||
return "New vulnerability"
|
||||
parts = description.split(".")
|
||||
return parts[0].strip() + "."
|
||||
|
||||
|
||||
def build_cve_message(cve):
|
||||
cve_id = cve.get("id") or "UNKNOWN"
|
||||
published = cve.get("published") or "N/A"
|
||||
subject = escape(extract_subject(cve.get("description", "")))
|
||||
desc = escape((cve.get("description", "") or "")[:1200])
|
||||
|
||||
# Determine severity from CVSS scores
|
||||
severity_label = "UNKNOWN"
|
||||
severity_emoji = "⚪"
|
||||
cvss_scores = cve.get("cvss") or []
|
||||
if cvss_scores:
|
||||
max_score = max(cvss_scores)
|
||||
severity = cvss_to_severity(max_score)
|
||||
if severity == "critical":
|
||||
severity_label = "CRITICAL"
|
||||
severity_emoji = "🔴"
|
||||
elif severity == "high":
|
||||
severity_label = "HIGH"
|
||||
severity_emoji = "🟠"
|
||||
elif severity == "medium":
|
||||
severity_label = "MEDIUM"
|
||||
severity_emoji = "🟡"
|
||||
elif severity == "low":
|
||||
severity_label = "LOW"
|
||||
severity_emoji = "🟢"
|
||||
|
||||
parts = [
|
||||
f"🚨 <b>{escape(cve_id)}</b>",
|
||||
f"🗓 <b>Published:</b> {escape(published)}",
|
||||
f"{severity_emoji} <b>Severity:</b> {severity_label}",
|
||||
f"📌 <b>Summary:</b> {subject}"
|
||||
]
|
||||
if cve.get("cvss_str"):
|
||||
parts.append("🔥 <b>CVSS Score:</b> " + ", ".join(escape(s) for s in cve.get("cvss_str")))
|
||||
parts.append("")
|
||||
parts.append(desc)
|
||||
if cve.get("references"):
|
||||
parts.append(f'<a href="{escape(cve["references"][0], quote=True)}">🔗 Reference</a>')
|
||||
msg = "\n".join(parts)
|
||||
if len(msg) > MAX_MSG_LEN:
|
||||
msg = msg[:MAX_MSG_LEN].rsplit("\n", 1)[0] + "\n…"
|
||||
return msg
|
||||
|
||||
|
||||
# ---------------- backfill ----------------
|
||||
def backfill_to_user(chat_id, since_iso, max_items=15):
|
||||
"""Backfill - doesn't update last_sent"""
|
||||
until_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
params = {"pubStartDate": since_iso, "pubEndDate": until_iso, "resultsPerPage": 2000, "startIndex": 0}
|
||||
sent = 0
|
||||
|
||||
subs = load_subscribers()
|
||||
users = subs.get("users", {})
|
||||
cid = str(chat_id)
|
||||
user = users.get(cid, {})
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = nvd_request(params)
|
||||
vulns = data.get("vulnerabilities") or []
|
||||
for v in vulns:
|
||||
info = extract_cve_info(v)
|
||||
|
||||
if not matches_user_preferences(info, user):
|
||||
continue
|
||||
|
||||
msg = build_cve_message(info)
|
||||
if send_telegram_to_chat(chat_id, msg):
|
||||
sent += 1
|
||||
if sent >= max_items:
|
||||
return sent
|
||||
time.sleep(0.15)
|
||||
|
||||
total = data.get("totalResults") or 0
|
||||
start = params.get("startIndex", 0)
|
||||
per = params.get("resultsPerPage", 2000)
|
||||
if not total or start + per >= int(total):
|
||||
break
|
||||
params["startIndex"] = start + per
|
||||
except Exception as e:
|
||||
print("Backfill error:", e)
|
||||
return sent
|
||||
|
||||
|
||||
# ---------------- main loop ----------------
|
||||
def run_loop():
|
||||
if not TELEGRAM_API:
|
||||
print("Warning: TELEGRAM_BOT_TOKEN not set")
|
||||
return
|
||||
|
||||
state = load_state()
|
||||
|
||||
try:
|
||||
poll_telegram_updates_once()
|
||||
except Exception as e:
|
||||
print("Initial poll failed:", e)
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
poll_telegram_updates_once()
|
||||
except Exception as e:
|
||||
print("poll error:", e)
|
||||
|
||||
last_pub = state.get("last_pub") or (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
print(f"[{datetime.now(timezone.utc).isoformat()}] Querying NVD from {last_pub} to {now_iso}")
|
||||
|
||||
new_cves = query_new_cves(last_pub, now_iso)
|
||||
subs_data = load_subscribers()
|
||||
users = subs_data.get("users", {}) or {}
|
||||
|
||||
if not new_cves:
|
||||
print(f"No new CVEs")
|
||||
else:
|
||||
print(f"Found {len(new_cves)} new CVE(s)")
|
||||
|
||||
# Check if we're in quiet hours
|
||||
in_quiet_hours = is_quiet_hours()
|
||||
if in_quiet_hours:
|
||||
print(f"⏰ Quiet hours active - queuing messages for later")
|
||||
|
||||
for c in new_cves:
|
||||
msg = build_cve_message(c)
|
||||
|
||||
poll_telegram_updates_once()
|
||||
subs_data = load_subscribers()
|
||||
users = subs_data.get("users", {}) or {}
|
||||
|
||||
if not users:
|
||||
continue
|
||||
|
||||
for cid_str, u in list(users.items()):
|
||||
try:
|
||||
u_last = u.get("last_sent")
|
||||
if not u_last:
|
||||
users[cid_str]["last_sent"] = now_iso()
|
||||
continue
|
||||
|
||||
send_it = True
|
||||
try:
|
||||
c_pub = dateparser.parse(c.get("published"))
|
||||
u_dt = dateparser.parse(u_last)
|
||||
if c_pub and u_dt:
|
||||
send_it = c_pub > u_dt
|
||||
except Exception:
|
||||
send_it = True
|
||||
|
||||
if not send_it:
|
||||
continue
|
||||
|
||||
if not matches_user_preferences(c, u):
|
||||
continue
|
||||
|
||||
# During quiet hours, only send CRITICAL severity CVEs
|
||||
if in_quiet_hours:
|
||||
cvss_scores = c.get("cvss") or []
|
||||
is_critical = any(cvss_to_severity(score) == "critical" for score in cvss_scores)
|
||||
if not is_critical:
|
||||
print(f"⏰ Skipping non-critical CVE {c.get('id')} during quiet hours")
|
||||
# Update last_sent so we don't send it later
|
||||
users[cid_str]["last_sent"] = c.get("published")
|
||||
continue
|
||||
|
||||
ok = send_telegram_to_chat(u["chat_id"], msg)
|
||||
if ok:
|
||||
users[cid_str]["last_sent"] = c.get("published")
|
||||
users[cid_str]["last_seen"] = datetime.now(timezone.utc).isoformat()
|
||||
except Exception as e:
|
||||
print("Error sending:", e)
|
||||
time.sleep(0.12)
|
||||
|
||||
subs_data["users"] = users
|
||||
save_subscribers(subs_data)
|
||||
time.sleep(0.2)
|
||||
|
||||
state["last_pub"] = now_iso
|
||||
save_state(state)
|
||||
|
||||
except requests.HTTPError as e:
|
||||
print("HTTP error:", e)
|
||||
time.sleep(60)
|
||||
except Exception as e:
|
||||
print("Error:", e)
|
||||
time.sleep(30)
|
||||
|
||||
slept = 0
|
||||
total_sleep = max(10, POLL_MINUTES * 60)
|
||||
while slept < total_sleep:
|
||||
time.sleep(TG_POLL_SECONDS)
|
||||
slept += TG_POLL_SECONDS
|
||||
try:
|
||||
poll_telegram_updates_once()
|
||||
except Exception as e:
|
||||
print("poll error during sleep:", e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_loop()
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests
|
||||
python-dateutil
|
||||
Reference in New Issue
Block a user