Compare commits
7 Commits
ad69c6a266
...
ce26202ce1
| Author | SHA1 | Date | |
|---|---|---|---|
| ce26202ce1 | |||
| 03a3c50354 | |||
| e8866ce4e6 | |||
| 1b2eb74cf1 | |||
| ce3b813e8b | |||
| 1b9a5de9e3 | |||
| 63f0ac74bc |
@@ -7,9 +7,10 @@ Telegram bot that monitors cybersecurity RSS feeds and ransomware.live victim da
|
|||||||
```
|
```
|
||||||
rss_telegram_bot/
|
rss_telegram_bot/
|
||||||
├── threat_intel_bot.py # Main bot — commands, subscriptions, alert dispatch
|
├── threat_intel_bot.py # Main bot — commands, subscriptions, alert dispatch
|
||||||
├── rss_manager.py # Feed fetching, dedup, message formatting
|
├── rss_manager.py # Feed fetching, dedup (incl. cross-source), message formatting
|
||||||
├── content_classifier.py # CVE/actor/malware extraction
|
├── content_classifier.py # CVE/actor/malware/MITRE extraction
|
||||||
├── ransomware_fetcher.py # ransomware.live PRO API — victim feed
|
├── ransomware_fetcher.py # ransomware.live PRO API — victim feed, LV/EE/LT only
|
||||||
|
├── bot_config.py # Shared category, feed-file, and tuning config
|
||||||
├── check_feeds.py # CLI utility to check feed health
|
├── check_feeds.py # CLI utility to check feed health
|
||||||
├── feeds/
|
├── feeds/
|
||||||
│ ├── news_feeds.json
|
│ ├── news_feeds.json
|
||||||
@@ -22,14 +23,16 @@ rss_telegram_bot/
|
|||||||
│ └── review.py # pretty-print and filter results.jsonl
|
│ └── review.py # pretty-print and filter results.jsonl
|
||||||
├── .env # not committed — see Setup
|
├── .env # not committed — see Setup
|
||||||
├── subscribers.json # auto-managed — chat/topic subscriptions
|
├── subscribers.json # auto-managed — chat/topic subscriptions
|
||||||
├── seen_articles.db # SQLite — tracks sent RSS articles (7-day retention)
|
├── seen_articles.db # SQLite — tracks sent RSS articles + titles (14-day retention)
|
||||||
└── seen_victims.db # SQLite — tracks sent ransomware victims (7-day retention)
|
└── seen_victims.db # SQLite — tracks sent ransomware victims (14-day retention)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install python-telegram-bot feedparser aiohttp beautifulsoup4
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Create `.env`:
|
Create `.env`:
|
||||||
@@ -54,7 +57,7 @@ python3 threat_intel_bot.py
|
|||||||
| `/stats` | Check feed health and subscriber count |
|
| `/stats` | Check feed health and subscriber count |
|
||||||
| `/help` | Show commands |
|
| `/help` | Show commands |
|
||||||
|
|
||||||
Categories: `news`, `malware`, `threat_intel`, `osint`, `research`
|
Categories: `news`, `malware`, `threat_intel`, `osint`, `research`, `ransomware`
|
||||||
|
|
||||||
**Telegram Topics:** Run `/on_<category>` inside each topic to route categories to separate threads.
|
**Telegram Topics:** Run `/on_<category>` inside each topic to route categories to separate threads.
|
||||||
|
|
||||||
@@ -75,10 +78,10 @@ Description
|
|||||||
🔗 Read Full Article
|
🔗 Read Full Article
|
||||||
```
|
```
|
||||||
|
|
||||||
Ransomware victims (posted to the malware topic):
|
Ransomware victims (posted to the `ransomware` topic, separate from `malware` so a busy leak day doesn't bury other malware sources):
|
||||||
|
|
||||||
```
|
```
|
||||||
🦠 company.com claimed by LockBit
|
💰 company.com claimed by LockBit
|
||||||
|
|
||||||
Sector: Finance | Country: US
|
Sector: Finance | Country: US
|
||||||
Data description from leak post
|
Data description from leak post
|
||||||
@@ -107,7 +110,7 @@ Add or remove feeds by editing the relevant `feeds/*.json` file, then restart th
|
|||||||
|
|
||||||
### ransomware.live PRO API
|
### ransomware.live PRO API
|
||||||
|
|
||||||
When `RANSOMWARE_LIVE_API_KEY` is set, the bot polls the PRO API every 5 minutes for newly discovered ransomware victims. New entries are posted to the `malware` topic. Victims are tracked in `seen_victims.db` with 7-day retention.
|
When `RANSOMWARE_LIVE_API_KEY` is set, the bot polls the PRO API every 5 minutes for newly discovered ransomware victims, filtered to companies in Latvia, Estonia, and Lithuania (`ALLOWED_RANSOMWARE_COUNTRIES` in `bot_config.py`) — the global feed is available directly on ransomware.live. New entries are posted to the `ransomware` topic (subscribe with `/on_ransomware`), kept separate from RSS `malware` articles. Victims are tracked in `seen_victims.db` with 14-day retention.
|
||||||
|
|
||||||
## Classification
|
## Classification
|
||||||
|
|
||||||
@@ -150,10 +153,10 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=ubuntu
|
||||||
WorkingDirectory=/root/tele-bots/rss_splited_bot
|
WorkingDirectory=/home/ubuntu/rss-tele-bot
|
||||||
EnvironmentFile=/root/tele-bots/rss_splited_bot/.env
|
EnvironmentFile=/home/ubuntu/rss-tele-bot/.env
|
||||||
ExecStart=/root/tele-bots/rss_splited_bot/venv/bin/python3 threat_intel_bot.py
|
ExecStart=/home/ubuntu/rss-tele-bot/venv/bin/python3 threat_intel_bot.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|
||||||
@@ -167,18 +170,21 @@ sudo systemctl enable --now rss-bot
|
|||||||
sudo journalctl -u rss-bot -f
|
sudo journalctl -u rss-bot -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Currently deployed and running this way — `enabled` (survives reboot) and `Restart=always` (survives crashes), so it no longer depends on an active SSH session.
|
||||||
|
|
||||||
## Deploying Updates
|
## Deploying Updates
|
||||||
|
|
||||||
From the local project directory:
|
From the local project directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rsync -avz ransomware_fetcher.py threat_intel_bot.py rss_manager.py content_classifier.py feeds/threat_intel_feeds.json feeds/news_feeds.json feeds/research_feeds.json root@deployer:/root/tele-bots/rss_splited_bot/
|
rsync -avz --relative ransomware_fetcher.py threat_intel_bot.py rss_manager.py content_classifier.py bot_config.py check_feeds.py requirements.txt ./feeds/threat_intel_feeds.json ./feeds/news_feeds.json ./feeds/research_feeds.json ./feeds/malware_feeds.json ./feeds/osint_feeds.json ubuntu@<vps-ip>:/home/ubuntu/rss-tele-bot/
|
||||||
ssh root@deployer "systemctl restart rss-bot"
|
ssh ubuntu@<vps-ip> "sudo systemctl restart rss-bot"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- First run marks all current articles and victims as seen — no flood on startup
|
- First run (empty seen-DB) marks all current articles and victims as seen — no flood on startup. A restart of an already-initialized bot instead fetches and alerts on anything published while it was stopped, so a redeploy doesn't silently drop real alerts.
|
||||||
- Polling interval: 5 minutes
|
- Polling interval: 5 minutes
|
||||||
- Articles published within the last 48 hours are processed (rolling window, not calendar day)
|
- Articles published within the last 48 hours are processed (rolling window, not calendar day)
|
||||||
- `seen_articles.db` and `seen_victims.db` auto-purge entries older than 7 days
|
- Cross-source duplicates (the same story from a different outlet) are caught by fuzzy title matching, not just exact URL/text matches
|
||||||
|
- `seen_articles.db` and `seen_victims.db` auto-purge entries older than 14 days
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shared category, feed-file, and tuning configuration.
|
||||||
|
|
||||||
|
Single source of truth for values that used to be copy-pasted across
|
||||||
|
threat_intel_bot.py, check_feeds.py, and validation/run_validation.py —
|
||||||
|
edit here, not in the callers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# category -> (display label, emoji)
|
||||||
|
CATEGORY_LABELS = {
|
||||||
|
"news": ("News", "📰"),
|
||||||
|
"malware": ("Malware", "🦠"),
|
||||||
|
"threat_intel": ("Threat Intel", "🛰️"),
|
||||||
|
"osint": ("OSINT", "🕵️"),
|
||||||
|
"research": ("Research", "🔬"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# category -> RSS feed list file. Every key here must also be in CATEGORY_LABELS.
|
||||||
|
CATEGORY_FEEDS = {
|
||||||
|
"news": "feeds/news_feeds.json",
|
||||||
|
"malware": "feeds/malware_feeds.json",
|
||||||
|
"threat_intel": "feeds/threat_intel_feeds.json",
|
||||||
|
"osint": "feeds/osint_feeds.json",
|
||||||
|
"research": "feeds/research_feeds.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Categories with no RSS feed of their own (e.g. fed by an API poller instead).
|
||||||
|
# Subscribable via /on_<category>, but excluded from the RSS polling/status loops.
|
||||||
|
EXTRA_CATEGORY_LABELS = {
|
||||||
|
"ransomware": ("Ransomware", "💰"),
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY_EMOJIS = {
|
||||||
|
key: emoji for key, (_label, emoji) in {**CATEGORY_LABELS, **EXTRA_CATEGORY_LABELS}.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# How often the bot/validation monitor polls feeds and the ransomware.live API.
|
||||||
|
POLL_INTERVAL_SECONDS = 300
|
||||||
|
|
||||||
|
# How long "seen" fingerprints and recently-sent titles are kept for dedup.
|
||||||
|
# Only used to suppress re-alerting on the same story — no other retention need.
|
||||||
|
SEEN_RETENTION_DAYS = 14
|
||||||
|
|
||||||
|
# Only alert on ransomware.live victims headquartered in these ISO-2 countries.
|
||||||
|
# The general global feed is available directly on ransomware.live's own site.
|
||||||
|
ALLOWED_RANSOMWARE_COUNTRIES = {"LV", "EE", "LT"}
|
||||||
|
|
||||||
|
# Cross-source near-duplicate title matching (catches the same story reported
|
||||||
|
# by two different outlets with different URLs/wording).
|
||||||
|
TITLE_DEDUP_WINDOW_HOURS = 72
|
||||||
|
TITLE_SIMILARITY_THRESHOLD = 0.72
|
||||||
+1
-8
@@ -4,14 +4,7 @@ Quick script to check all RSS feeds status
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from rss_manager import RSSFeedManager
|
from rss_manager import RSSFeedManager
|
||||||
|
from bot_config import CATEGORY_FEEDS
|
||||||
CATEGORY_FEEDS = {
|
|
||||||
"news": "feeds/news_feeds.json",
|
|
||||||
"malware": "feeds/malware_feeds.json",
|
|
||||||
"threat_intel": "feeds/threat_intel_feeds.json",
|
|
||||||
"osint": "feeds/osint_feeds.json",
|
|
||||||
"research": "feeds/research_feeds.json",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def check_all_feeds():
|
async def check_all_feeds():
|
||||||
totals = {}
|
totals = {}
|
||||||
|
|||||||
+1
-23
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Content Classifier for Threat Intelligence RSS Bot
|
Content Classifier for Threat Intelligence RSS Bot
|
||||||
Extracts CVEs, threat actors, malware families, MITRE techniques, and IOCs.
|
Extracts CVEs, threat actors, malware families, and MITRE technique IDs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -47,11 +47,6 @@ class ContentClassifier:
|
|||||||
|
|
||||||
MITRE_PATTERN = re.compile(r'T\d{4}(?:\.\d{3})?', re.IGNORECASE)
|
MITRE_PATTERN = re.compile(r'T\d{4}(?:\.\d{3})?', re.IGNORECASE)
|
||||||
CVE_PATTERN = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE)
|
CVE_PATTERN = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE)
|
||||||
IP_PATTERN = re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b')
|
|
||||||
DOMAIN_PATTERN = re.compile(r'\b[a-z0-9]+(?:[\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6}\b', re.IGNORECASE)
|
|
||||||
HASH_MD5_PATTERN = re.compile(r'\b[a-f0-9]{32}\b', re.IGNORECASE)
|
|
||||||
HASH_SHA1_PATTERN = re.compile(r'\b[a-f0-9]{40}\b', re.IGNORECASE)
|
|
||||||
HASH_SHA256_PATTERN = re.compile(r'\b[a-f0-9]{64}\b', re.IGNORECASE)
|
|
||||||
|
|
||||||
def extract_cves(self, text: str) -> List[str]:
|
def extract_cves(self, text: str) -> List[str]:
|
||||||
return list(set(cve.upper() for cve in self.CVE_PATTERN.findall(text)))
|
return list(set(cve.upper() for cve in self.CVE_PATTERN.findall(text)))
|
||||||
@@ -75,21 +70,6 @@ class ContentClassifier:
|
|||||||
if re.search(r'\b' + re.escape(family.lower()) + r'\b', text_lower)
|
if re.search(r'\b' + re.escape(family.lower()) + r'\b', text_lower)
|
||||||
))
|
))
|
||||||
|
|
||||||
def extract_iocs(self, text: str) -> Dict[str, List[str]]:
|
|
||||||
iocs: Dict[str, List[str]] = {
|
|
||||||
'ips': [], 'domains': [], 'md5': [], 'sha1': [], 'sha256': []
|
|
||||||
}
|
|
||||||
for ip in self.IP_PATTERN.findall(text):
|
|
||||||
if all(0 <= int(o) <= 255 for o in ip.split('.')):
|
|
||||||
iocs['ips'].append(ip)
|
|
||||||
iocs['md5'] = self.HASH_MD5_PATTERN.findall(text)
|
|
||||||
iocs['sha1'] = self.HASH_SHA1_PATTERN.findall(text)
|
|
||||||
iocs['sha256'] = self.HASH_SHA256_PATTERN.findall(text)
|
|
||||||
iocs['domains'] = [m.group(0).lower() for m in self.DOMAIN_PATTERN.finditer(text)]
|
|
||||||
for key in iocs:
|
|
||||||
iocs[key] = list(set(iocs[key]))[:5]
|
|
||||||
return iocs
|
|
||||||
|
|
||||||
def classify_article(self, article: Dict) -> Dict:
|
def classify_article(self, article: Dict) -> Dict:
|
||||||
combined_text = f"{article.get('title', '')} {article.get('description', '')}"
|
combined_text = f"{article.get('title', '')} {article.get('description', '')}"
|
||||||
|
|
||||||
@@ -97,13 +77,11 @@ class ContentClassifier:
|
|||||||
mitre_techniques = self.extract_mitre_techniques(combined_text)
|
mitre_techniques = self.extract_mitre_techniques(combined_text)
|
||||||
threat_actors = self.extract_threat_actors(combined_text)
|
threat_actors = self.extract_threat_actors(combined_text)
|
||||||
malware_families = self.extract_malware_families(combined_text)
|
malware_families = self.extract_malware_families(combined_text)
|
||||||
iocs = self.extract_iocs(combined_text)
|
|
||||||
|
|
||||||
article['cves'] = cves
|
article['cves'] = cves
|
||||||
article['mitre_techniques'] = mitre_techniques
|
article['mitre_techniques'] = mitre_techniques
|
||||||
article['threat_actors'] = threat_actors
|
article['threat_actors'] = threat_actors
|
||||||
article['malware_families'] = malware_families
|
article['malware_families'] = malware_families
|
||||||
article['iocs'] = iocs
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Classified: {article.get('title', '')[:50]}... | "
|
f"Classified: {article.get('title', '')[:50]}... | "
|
||||||
|
|||||||
+20
-5
@@ -14,6 +14,8 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
|
from bot_config import SEEN_RETENTION_DAYS, ALLOWED_RANSOMWARE_COUNTRIES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
API_BASE = "https://api-pro.ransomware.live"
|
API_BASE = "https://api-pro.ransomware.live"
|
||||||
@@ -45,9 +47,15 @@ class RansomwareFetcher:
|
|||||||
"SELECT 1 FROM seen_victims WHERE victim_id = ?", (victim_id,)
|
"SELECT 1 FROM seen_victims WHERE victim_id = ?", (victim_id,)
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
|
|
||||||
|
def is_first_run(self) -> bool:
|
||||||
|
"""True if no victims have ever been recorded as seen (fresh DB)."""
|
||||||
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
|
count = conn.execute("SELECT COUNT(1) FROM seen_victims").fetchone()[0]
|
||||||
|
return count == 0
|
||||||
|
|
||||||
def mark_seen(self, victim_id: str):
|
def mark_seen(self, victim_id: str):
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=SEEN_RETENTION_DAYS)).isoformat()
|
||||||
with sqlite3.connect(self.seen_file) as conn:
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
conn.execute("DELETE FROM seen_victims WHERE seen_at < ?", (cutoff,))
|
conn.execute("DELETE FROM seen_victims WHERE seen_at < ?", (cutoff,))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -95,6 +103,13 @@ class RansomwareFetcher:
|
|||||||
victims = await self._fetch_month(session, year, month)
|
victims = await self._fetch_month(session, year, month)
|
||||||
all_victims.extend(victims)
|
all_victims.extend(victims)
|
||||||
|
|
||||||
|
# Only care about victims in these countries — the general global feed
|
||||||
|
# is available directly on ransomware.live's own site.
|
||||||
|
all_victims = [
|
||||||
|
v for v in all_victims
|
||||||
|
if (v.get("country") or "").strip().upper() in ALLOWED_RANSOMWARE_COUNTRIES
|
||||||
|
]
|
||||||
|
|
||||||
new_victims = []
|
new_victims = []
|
||||||
for v in all_victims:
|
for v in all_victims:
|
||||||
discovered_str = v.get("discovered", "")
|
discovered_str = v.get("discovered", "")
|
||||||
@@ -104,6 +119,8 @@ class RansomwareFetcher:
|
|||||||
discovered_dt = datetime.fromisoformat(discovered_str.replace("Z", "+00:00"))
|
discovered_dt = datetime.fromisoformat(discovered_str.replace("Z", "+00:00"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
if discovered_dt.tzinfo is None:
|
||||||
|
discovered_dt = discovered_dt.replace(tzinfo=timezone.utc)
|
||||||
if discovered_dt < cutoff:
|
if discovered_dt < cutoff:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -167,13 +184,11 @@ class RansomwareFetcher:
|
|||||||
"url": victim.get("permalink", ""),
|
"url": victim.get("permalink", ""),
|
||||||
"published_human": published_human,
|
"published_human": published_human,
|
||||||
"source": "ransomware.live",
|
"source": "ransomware.live",
|
||||||
"category": "malware",
|
"category": "ransomware",
|
||||||
"feed_type": "malware",
|
"feed_type": "ransomware",
|
||||||
"thumbnail": screenshot,
|
"thumbnail": screenshot,
|
||||||
"cves": [],
|
"cves": [],
|
||||||
"threat_actors": [],
|
"threat_actors": [],
|
||||||
"malware_families": [group.title()],
|
"malware_families": [group.title()],
|
||||||
"mitre_techniques": [],
|
"mitre_techniques": [],
|
||||||
"iocs": {},
|
|
||||||
"_victim_id": victim.get("id", ""),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Hand-curated dependency pins; this project does not have a lockfile.
|
||||||
|
python-telegram-bot==22.8
|
||||||
|
feedparser==6.0.14
|
||||||
|
aiohttp==3.14.3
|
||||||
|
beautifulsoup4==4.15.0
|
||||||
+68
-23
@@ -10,6 +10,7 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import difflib
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
@@ -18,6 +19,8 @@ from html import escape
|
|||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_config import CATEGORY_EMOJIS, SEEN_RETENTION_DAYS, TITLE_DEDUP_WINDOW_HOURS, TITLE_SIMILARITY_THRESHOLD
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
@@ -32,6 +35,7 @@ class RSSFeedManager:
|
|||||||
self.feed_type = feed_type # 'daily', 'blogs', or 'general'
|
self.feed_type = feed_type # 'daily', 'blogs', or 'general'
|
||||||
self.feeds = {}
|
self.feeds = {}
|
||||||
self.seen_articles = {} # Changed to dict to store timestamps
|
self.seen_articles = {} # Changed to dict to store timestamps
|
||||||
|
self.recent_titles: List[str] = [] # normalized titles sent recently, for cross-source dedup
|
||||||
self.session = None
|
self.session = None
|
||||||
self.first_run = True
|
self.first_run = True
|
||||||
self.load_feeds()
|
self.load_feeds()
|
||||||
@@ -71,6 +75,7 @@ class RSSFeedManager:
|
|||||||
self._ensure_seen_db()
|
self._ensure_seen_db()
|
||||||
self._migrate_legacy_seen_json()
|
self._migrate_legacy_seen_json()
|
||||||
self._load_seen_from_sqlite()
|
self._load_seen_from_sqlite()
|
||||||
|
self._load_recent_titles()
|
||||||
|
|
||||||
def _ensure_seen_db(self):
|
def _ensure_seen_db(self):
|
||||||
"""Create seen-article database and indexes if missing."""
|
"""Create seen-article database and indexes if missing."""
|
||||||
@@ -87,10 +92,34 @@ class RSSFeedManager:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_seen_articles_seen_at ON seen_articles (seen_at)"
|
"CREATE INDEX IF NOT EXISTS idx_seen_articles_seen_at ON seen_articles (seen_at)"
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sent_titles (
|
||||||
|
title_norm TEXT NOT NULL,
|
||||||
|
seen_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sent_titles_seen_at ON sent_titles (seen_at)"
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error initializing seen-article database: {e}")
|
logger.error(f"Error initializing seen-article database: {e}")
|
||||||
|
|
||||||
|
def _load_recent_titles(self):
|
||||||
|
"""Load normalized titles sent within the cross-source dedup window."""
|
||||||
|
try:
|
||||||
|
cutoff_iso = (datetime.now(timezone.utc) - timedelta(hours=TITLE_DEDUP_WINDOW_HOURS)).isoformat()
|
||||||
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT title_norm FROM sent_titles WHERE seen_at >= ?", (cutoff_iso,)
|
||||||
|
)
|
||||||
|
self.recent_titles = [row[0] for row in cursor.fetchall()]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading recent titles: {e}")
|
||||||
|
self.recent_titles = []
|
||||||
|
|
||||||
def _load_seen_from_sqlite(self):
|
def _load_seen_from_sqlite(self):
|
||||||
"""Load seen keys from SQLite into memory."""
|
"""Load seen keys from SQLite into memory."""
|
||||||
try:
|
try:
|
||||||
@@ -149,7 +178,7 @@ class RSSFeedManager:
|
|||||||
def save_seen_articles(self):
|
def save_seen_articles(self):
|
||||||
"""Save seen article keys with retention in SQLite."""
|
"""Save seen article keys with retention in SQLite."""
|
||||||
try:
|
try:
|
||||||
cutoff_time = datetime.now(timezone.utc) - timedelta(days=7)
|
cutoff_time = datetime.now(timezone.utc) - timedelta(days=SEEN_RETENTION_DAYS)
|
||||||
cutoff_iso = cutoff_time.isoformat()
|
cutoff_iso = cutoff_time.isoformat()
|
||||||
cleaned_articles = {}
|
cleaned_articles = {}
|
||||||
with sqlite3.connect(self.seen_file) as conn:
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
@@ -167,12 +196,22 @@ class RSSFeedManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving seen articles: {e}")
|
logger.error(f"Error saving seen articles: {e}")
|
||||||
|
|
||||||
def generate_article_hash(self, article: Dict) -> str:
|
def _save_sent_title(self, title: str, seen_at: str):
|
||||||
"""Generate unique hash for article based on URL only for better duplicate detection"""
|
"""Record a sent article's normalized title for cross-source dedup, pruning old rows."""
|
||||||
# Use only the URL for hash to catch duplicates across different RSS sources
|
title_norm = self.normalize_text(title)
|
||||||
# Different sources may have same article with different titles/dates
|
if not title_norm:
|
||||||
url = article.get('link', '')
|
return
|
||||||
return hashlib.md5(url.encode()).hexdigest()
|
try:
|
||||||
|
cutoff_iso = (datetime.now(timezone.utc) - timedelta(days=SEEN_RETENTION_DAYS)).isoformat()
|
||||||
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
|
conn.execute("DELETE FROM sent_titles WHERE seen_at < ?", (cutoff_iso,))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sent_titles (title_norm, seen_at) VALUES (?, ?)",
|
||||||
|
(title_norm, seen_at),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving sent title: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def canonicalize_url(url: str) -> str:
|
def canonicalize_url(url: str) -> str:
|
||||||
@@ -220,22 +259,36 @@ class RSSFeedManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def is_article_recent(self, published_dt: datetime) -> bool:
|
def is_article_recent(self, published_dt: datetime) -> bool:
|
||||||
"""Allow articles published within the last 48 hours."""
|
"""Allow articles published within the last 48 hours (with brief clock-skew tolerance)."""
|
||||||
if published_dt.tzinfo is None:
|
if published_dt.tzinfo is None:
|
||||||
published_dt = published_dt.replace(tzinfo=timezone.utc)
|
published_dt = published_dt.replace(tzinfo=timezone.utc)
|
||||||
age = datetime.now(timezone.utc) - published_dt.astimezone(timezone.utc)
|
age_seconds = (datetime.now(timezone.utc) - published_dt.astimezone(timezone.utc)).total_seconds()
|
||||||
return age.total_seconds() <= 48 * 3600
|
return -300 <= age_seconds <= 48 * 3600
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def titles_are_near_duplicate(title_norm_a: str, title_norm_b: str) -> bool:
|
||||||
|
"""Fuzzy title match, tolerant of different outlets' wording for the same story."""
|
||||||
|
return difflib.SequenceMatcher(None, title_norm_a, title_norm_b).ratio() >= TITLE_SIMILARITY_THRESHOLD
|
||||||
|
|
||||||
|
def is_near_duplicate_title(self, title: str) -> bool:
|
||||||
|
"""Fuzzy-match against recently sent titles to catch the same story from a different source."""
|
||||||
|
title_norm = self.normalize_text(title)
|
||||||
|
if not title_norm:
|
||||||
|
return False
|
||||||
|
return any(self.titles_are_near_duplicate(title_norm, other) for other in self.recent_titles)
|
||||||
|
|
||||||
def is_duplicate_article(self, article: Dict) -> bool:
|
def is_duplicate_article(self, article: Dict) -> bool:
|
||||||
"""Check URL/content fingerprint duplicates against sent-history."""
|
"""Check URL/content fingerprint duplicates, plus fuzzy title matches against recent cross-source sends."""
|
||||||
fingerprints = self.get_article_fingerprints(article)
|
fingerprints = self.get_article_fingerprints(article)
|
||||||
url_key = fingerprints['url_key']
|
url_key = fingerprints['url_key']
|
||||||
content_key = fingerprints['content_key']
|
content_key = fingerprints['content_key']
|
||||||
return (url_key and url_key in self.seen_articles) or (content_key in self.seen_articles)
|
if (url_key and url_key in self.seen_articles) or (content_key in self.seen_articles):
|
||||||
|
return True
|
||||||
|
return self.is_near_duplicate_title(article.get('title', ''))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def mark_article_as_sent(cls, article: Dict, seen_file: str = "seen_articles.db"):
|
def mark_article_as_sent(cls, article: Dict, seen_file: str = "seen_articles.db"):
|
||||||
"""Persist fingerprints only after successful delivery."""
|
"""Persist fingerprints and title only after successful delivery."""
|
||||||
manager = cls(feeds_file="", seen_file=seen_file, feed_type=article.get('feed_type', 'general'))
|
manager = cls(feeds_file="", seen_file=seen_file, feed_type=article.get('feed_type', 'general'))
|
||||||
fingerprints = cls.get_article_fingerprints(article)
|
fingerprints = cls.get_article_fingerprints(article)
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
@@ -244,6 +297,7 @@ class RSSFeedManager:
|
|||||||
manager.seen_articles[fingerprints['url_key']] = now_iso
|
manager.seen_articles[fingerprints['url_key']] = now_iso
|
||||||
manager.seen_articles[fingerprints['content_key']] = now_iso
|
manager.seen_articles[fingerprints['content_key']] = now_iso
|
||||||
manager.save_seen_articles()
|
manager.save_seen_articles()
|
||||||
|
manager._save_sent_title(article.get('title', ''), now_iso)
|
||||||
|
|
||||||
def clean_html(self, text: str) -> str:
|
def clean_html(self, text: str) -> str:
|
||||||
"""Clean HTML tags and decode entities"""
|
"""Clean HTML tags and decode entities"""
|
||||||
@@ -322,7 +376,6 @@ class RSSFeedManager:
|
|||||||
content = entry.description
|
content = entry.description
|
||||||
|
|
||||||
article = {
|
article = {
|
||||||
'hash': self.generate_article_hash(entry),
|
|
||||||
'title': self.clean_html(getattr(entry, 'title', 'No Title')),
|
'title': self.clean_html(getattr(entry, 'title', 'No Title')),
|
||||||
'description': self.clean_html(content),
|
'description': self.clean_html(content),
|
||||||
'url': self.canonicalize_url(getattr(entry, 'link', '')),
|
'url': self.canonicalize_url(getattr(entry, 'link', '')),
|
||||||
@@ -449,15 +502,7 @@ class RSSFeedManager:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def format_telegram_message(article: Dict) -> Tuple[str, Optional[str]]:
|
def format_telegram_message(article: Dict) -> Tuple[str, Optional[str]]:
|
||||||
"""Format article for Telegram message"""
|
"""Format article for Telegram message"""
|
||||||
category_emojis = {
|
emoji = CATEGORY_EMOJIS.get(article.get('category', ''), '📰')
|
||||||
'news': '📰',
|
|
||||||
'malware': '🦠',
|
|
||||||
'threat_intel': '🛰️',
|
|
||||||
'osint': '🕵️',
|
|
||||||
'research': '🔬'
|
|
||||||
}
|
|
||||||
|
|
||||||
emoji = category_emojis.get(article.get('category', ''), '📰')
|
|
||||||
title = escape(article.get('title', 'No Title'))
|
title = escape(article.get('title', 'No Title'))
|
||||||
description = escape(article.get('description', ''))
|
description = escape(article.get('description', ''))
|
||||||
source = escape(article.get('source', 'Unknown Source'))
|
source = escape(article.get('source', 'Unknown Source'))
|
||||||
|
|||||||
+112
-59
@@ -9,7 +9,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ import aiohttp
|
|||||||
from rss_manager import RSSFeedManager
|
from rss_manager import RSSFeedManager
|
||||||
from content_classifier import ContentClassifier
|
from content_classifier import ContentClassifier
|
||||||
from ransomware_fetcher import RansomwareFetcher
|
from ransomware_fetcher import RansomwareFetcher
|
||||||
|
from bot_config import CATEGORY_LABELS, CATEGORY_FEEDS, EXTRA_CATEGORY_LABELS, POLL_INTERVAL_SECONDS
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -37,13 +38,20 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CATEGORY_CONFIG = {
|
CATEGORY_CONFIG = {
|
||||||
"news": {"label": "News", "emoji": "📰", "feeds_file": "feeds/news_feeds.json"},
|
key: {"label": label, "emoji": emoji, "feeds_file": CATEGORY_FEEDS[key]}
|
||||||
"malware": {"label": "Malware", "emoji": "🦠", "feeds_file": "feeds/malware_feeds.json"},
|
for key, (label, emoji) in CATEGORY_LABELS.items()
|
||||||
"threat_intel": {"label": "Threat Intel", "emoji": "🛰️", "feeds_file": "feeds/threat_intel_feeds.json"},
|
|
||||||
"osint": {"label": "OSINT", "emoji": "🕵️", "feeds_file": "feeds/osint_feeds.json"},
|
|
||||||
"research": {"label": "Research", "emoji": "🔬", "feeds_file": "feeds/research_feeds.json"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Categories that aren't RSS-fed (no feeds_file) and so are excluded from the
|
||||||
|
# RSS polling/status loops in monitor_feeds() and stats_command(), but are
|
||||||
|
# still subscribable via /on_<category> and shown in /start help.
|
||||||
|
EXTRA_CATEGORIES = {
|
||||||
|
key: {"label": label, "emoji": emoji}
|
||||||
|
for key, (label, emoji) in EXTRA_CATEGORY_LABELS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_CATEGORIES = {**CATEGORY_CONFIG, **EXTRA_CATEGORIES}
|
||||||
|
|
||||||
class ThreatIntelBot:
|
class ThreatIntelBot:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -85,7 +93,7 @@ class ThreatIntelBot:
|
|||||||
if isinstance(raw_feed_types, list):
|
if isinstance(raw_feed_types, list):
|
||||||
mapped = []
|
mapped = []
|
||||||
for feed in raw_feed_types:
|
for feed in raw_feed_types:
|
||||||
if feed in CATEGORY_CONFIG:
|
if feed in ALL_CATEGORIES:
|
||||||
mapped.append(feed)
|
mapped.append(feed)
|
||||||
elif feed == "daily":
|
elif feed == "daily":
|
||||||
mapped.extend(["news", "threat_intel", "osint", "malware"])
|
mapped.extend(["news", "threat_intel", "osint", "malware"])
|
||||||
@@ -123,7 +131,7 @@ class ThreatIntelBot:
|
|||||||
user_name = update.effective_user.first_name or "User"
|
user_name = update.effective_user.first_name or "User"
|
||||||
|
|
||||||
command_lines = []
|
command_lines = []
|
||||||
for key, cfg in CATEGORY_CONFIG.items():
|
for key, cfg in ALL_CATEGORIES.items():
|
||||||
command_lines.append(
|
command_lines.append(
|
||||||
f"{cfg['emoji']} `{('/on_' + key)}` / `{('/off_' + key)}` - {cfg['label']}"
|
f"{cfg['emoji']} `{('/on_' + key)}` / `{('/off_' + key)}` - {cfg['label']}"
|
||||||
)
|
)
|
||||||
@@ -151,14 +159,14 @@ class ThreatIntelBot:
|
|||||||
|
|
||||||
async def category_on_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE, category: str):
|
async def category_on_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE, category: str):
|
||||||
"""Handle /on_<category> command."""
|
"""Handle /on_<category> command."""
|
||||||
if category not in CATEGORY_CONFIG:
|
if category not in ALL_CATEGORIES:
|
||||||
return
|
return
|
||||||
|
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
message_thread_id = getattr(update.message, 'message_thread_id', None)
|
message_thread_id = getattr(update.message, 'message_thread_id', None)
|
||||||
subscriber_key = f"{chat_id}_{message_thread_id}" if message_thread_id else str(chat_id)
|
subscriber_key = f"{chat_id}_{message_thread_id}" if message_thread_id else str(chat_id)
|
||||||
is_forum_topic = message_thread_id is not None
|
is_forum_topic = message_thread_id is not None
|
||||||
cfg = CATEGORY_CONFIG[category]
|
cfg = ALL_CATEGORIES[category]
|
||||||
|
|
||||||
if subscriber_key not in self.subscribers:
|
if subscriber_key not in self.subscribers:
|
||||||
self.subscribers[subscriber_key] = {'topic_id': message_thread_id, 'feed_types': []}
|
self.subscribers[subscriber_key] = {'topic_id': message_thread_id, 'feed_types': []}
|
||||||
@@ -184,14 +192,14 @@ class ThreatIntelBot:
|
|||||||
|
|
||||||
async def category_off_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE, category: str):
|
async def category_off_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE, category: str):
|
||||||
"""Handle /off_<category> command."""
|
"""Handle /off_<category> command."""
|
||||||
if category not in CATEGORY_CONFIG:
|
if category not in ALL_CATEGORIES:
|
||||||
return
|
return
|
||||||
|
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
message_thread_id = getattr(update.message, 'message_thread_id', None)
|
message_thread_id = getattr(update.message, 'message_thread_id', None)
|
||||||
subscriber_key = f"{chat_id}_{message_thread_id}" if message_thread_id else str(chat_id)
|
subscriber_key = f"{chat_id}_{message_thread_id}" if message_thread_id else str(chat_id)
|
||||||
is_forum_topic = message_thread_id is not None
|
is_forum_topic = message_thread_id is not None
|
||||||
cfg = CATEGORY_CONFIG[category]
|
cfg = ALL_CATEGORIES[category]
|
||||||
|
|
||||||
if subscriber_key in self.subscribers and category in self.subscribers[subscriber_key].get('feed_types', []):
|
if subscriber_key in self.subscribers and category in self.subscribers[subscriber_key].get('feed_types', []):
|
||||||
self.subscribers[subscriber_key]['feed_types'].remove(category)
|
self.subscribers[subscriber_key]['feed_types'].remove(category)
|
||||||
@@ -224,7 +232,7 @@ class ThreatIntelBot:
|
|||||||
|
|
||||||
if subscriber_key not in self.subscribers:
|
if subscriber_key not in self.subscribers:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
"This topic is not subscribed yet. Use /on_news, /on_threat_intel, /on_malware, /on_osint, or /on_research first.",
|
"This topic is not subscribed yet. Use /on_news, /on_threat_intel, /on_malware, /on_osint, /on_research, or /on_ransomware first.",
|
||||||
message_thread_id=message_thread_id if message_thread_id else None
|
message_thread_id=message_thread_id if message_thread_id else None
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -393,26 +401,101 @@ class ThreatIntelBot:
|
|||||||
logger.error(f"Error sending alert: {e}")
|
logger.error(f"Error sending alert: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _classify_and_send(self, articles: List[Dict]) -> int:
|
||||||
|
"""Deduplicate a batch (exact + fuzzy cross-source title match), classify, and send.
|
||||||
|
|
||||||
|
A single bad article is logged and skipped rather than aborting the rest
|
||||||
|
of the batch (and, since ransomware polling runs after this in the caller,
|
||||||
|
rather than aborting that too).
|
||||||
|
"""
|
||||||
|
unique_articles = []
|
||||||
|
batch_seen_url = set()
|
||||||
|
batch_seen_content = set()
|
||||||
|
batch_titles: List[str] = []
|
||||||
|
for article in articles:
|
||||||
|
fp = RSSFeedManager.get_article_fingerprints(article)
|
||||||
|
url_key = fp['url_key']
|
||||||
|
content_key = fp['content_key']
|
||||||
|
if (url_key and url_key in batch_seen_url) or (content_key in batch_seen_content):
|
||||||
|
continue
|
||||||
|
title_norm = RSSFeedManager.normalize_text(article.get('title', ''))
|
||||||
|
if title_norm and any(
|
||||||
|
RSSFeedManager.titles_are_near_duplicate(title_norm, other) for other in batch_titles
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if url_key:
|
||||||
|
batch_seen_url.add(url_key)
|
||||||
|
batch_seen_content.add(content_key)
|
||||||
|
if title_norm:
|
||||||
|
batch_titles.append(title_norm)
|
||||||
|
unique_articles.append(article)
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for article in unique_articles:
|
||||||
|
try:
|
||||||
|
classified = self.classifier.classify_article(article)
|
||||||
|
sent = await self.send_alert(classified)
|
||||||
|
if sent:
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing article {article.get('title', '')[:50]!r}: {e}")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
async def _send_ransomware_victims(self, victims: List[Dict]) -> int:
|
||||||
|
"""Convert and send ransomware.live victims; one bad record doesn't stop the rest."""
|
||||||
|
victim_count = 0
|
||||||
|
for victim in victims:
|
||||||
|
try:
|
||||||
|
article = RansomwareFetcher.to_article(victim)
|
||||||
|
sent = await self.send_alert(article)
|
||||||
|
if sent:
|
||||||
|
self.ransomware_fetcher.mark_seen(victim.get("id", ""))
|
||||||
|
victim_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing ransomware victim {victim.get('id', '')}: {e}")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
return victim_count
|
||||||
|
|
||||||
async def monitor_feeds(self):
|
async def monitor_feeds(self):
|
||||||
"""Background task to monitor RSS feeds by category."""
|
"""Background task to monitor RSS feeds by category."""
|
||||||
logger.info("Starting RSS feed monitoring for category feeds...")
|
logger.info("Starting RSS feed monitoring for category feeds...")
|
||||||
|
|
||||||
# First run - mark existing articles and victims as seen, don't send alerts
|
# Only mark-without-alerting on a genuinely fresh seen-DB. On a restart of an
|
||||||
try:
|
# already-running bot, do a real fetch instead so articles published while the
|
||||||
for category, cfg in CATEGORY_CONFIG.items():
|
# service was stopped (e.g. during a deploy) still get delivered.
|
||||||
|
startup_articles: List[Dict] = []
|
||||||
|
for category, cfg in CATEGORY_CONFIG.items():
|
||||||
|
try:
|
||||||
async with RSSFeedManager(feeds_file=cfg["feeds_file"], feed_type=category) as manager:
|
async with RSSFeedManager(feeds_file=cfg["feeds_file"], feed_type=category) as manager:
|
||||||
logger.info(f"Initial {category.upper()} feed scan - marking existing articles as seen...")
|
if manager.first_run:
|
||||||
await manager.fetch_all_feeds(initial_run=True)
|
logger.info(f"Initial {category.upper()} feed scan - marking existing articles as seen...")
|
||||||
except Exception as e:
|
await manager.fetch_all_feeds(initial_run=True)
|
||||||
logger.error(f"Error in initial feed scan: {e}")
|
else:
|
||||||
|
articles = await manager.fetch_all_feeds(initial_run=False)
|
||||||
|
if articles:
|
||||||
|
logger.info(f"Restart {category.upper()} scan - {len(articles)} article(s) missed while stopped")
|
||||||
|
startup_articles.extend(articles)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in startup {category.upper()} scan: {e}")
|
||||||
|
|
||||||
|
if startup_articles:
|
||||||
|
sent = await self._classify_and_send(startup_articles)
|
||||||
|
logger.info(f"Delivered {sent} article(s) missed during downtime")
|
||||||
|
|
||||||
if self.ransomware_fetcher:
|
if self.ransomware_fetcher:
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
await self.ransomware_fetcher.fetch_new_victims(session, initial_run=True)
|
if self.ransomware_fetcher.is_first_run():
|
||||||
logger.info("Initial ransomware scan complete — existing victims marked as seen")
|
await self.ransomware_fetcher.fetch_new_victims(session, initial_run=True)
|
||||||
|
logger.info("Initial ransomware scan complete — existing victims marked as seen")
|
||||||
|
else:
|
||||||
|
missed_victims = await self.ransomware_fetcher.fetch_new_victims(session)
|
||||||
|
if missed_victims:
|
||||||
|
logger.info(f"Restart ransomware scan - {len(missed_victims)} victim(s) missed while stopped")
|
||||||
|
await self._send_ransomware_victims(missed_victims)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in initial ransomware scan: {e}")
|
logger.error(f"Error in startup ransomware scan: {e}")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -425,32 +508,9 @@ class ThreatIntelBot:
|
|||||||
if category_articles:
|
if category_articles:
|
||||||
logger.info(f"Found {len(category_articles)} new {category.upper()} articles")
|
logger.info(f"Found {len(category_articles)} new {category.upper()} articles")
|
||||||
|
|
||||||
# Deduplicate within this polling batch
|
sent_count = await self._classify_and_send(all_articles)
|
||||||
unique_articles = []
|
if all_articles:
|
||||||
batch_seen_url = set()
|
logger.info(f"Processed {len(all_articles)} candidate articles, delivered {sent_count}")
|
||||||
batch_seen_content = set()
|
|
||||||
for article in all_articles:
|
|
||||||
fp = RSSFeedManager.get_article_fingerprints(article)
|
|
||||||
url_key = fp['url_key']
|
|
||||||
content_key = fp['content_key']
|
|
||||||
if (url_key and url_key in batch_seen_url) or (content_key in batch_seen_content):
|
|
||||||
continue
|
|
||||||
if url_key:
|
|
||||||
batch_seen_url.add(url_key)
|
|
||||||
batch_seen_content.add(content_key)
|
|
||||||
unique_articles.append(article)
|
|
||||||
|
|
||||||
# Classify and send RSS articles
|
|
||||||
sent_count = 0
|
|
||||||
for article in unique_articles:
|
|
||||||
classified = self.classifier.classify_article(article)
|
|
||||||
sent = await self.send_alert(classified)
|
|
||||||
if sent:
|
|
||||||
sent_count += 1
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if unique_articles:
|
|
||||||
logger.info(f"Processed {len(unique_articles)} unique articles, delivered {sent_count}")
|
|
||||||
else:
|
else:
|
||||||
logger.info("No new articles found")
|
logger.info("No new articles found")
|
||||||
|
|
||||||
@@ -458,21 +518,14 @@ class ThreatIntelBot:
|
|||||||
if self.ransomware_fetcher:
|
if self.ransomware_fetcher:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
new_victims = await self.ransomware_fetcher.fetch_new_victims(session)
|
new_victims = await self.ransomware_fetcher.fetch_new_victims(session)
|
||||||
victim_count = 0
|
|
||||||
for victim in new_victims:
|
|
||||||
article = RansomwareFetcher.to_article(victim)
|
|
||||||
sent = await self.send_alert(article)
|
|
||||||
if sent:
|
|
||||||
self.ransomware_fetcher.mark_seen(victim.get("id", ""))
|
|
||||||
victim_count += 1
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
if new_victims:
|
if new_victims:
|
||||||
|
victim_count = await self._send_ransomware_victims(new_victims)
|
||||||
logger.info(f"Ransomware victims: {len(new_victims)} new, {victim_count} delivered")
|
logger.info(f"Ransomware victims: {len(new_victims)} new, {victim_count} delivered")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in feed monitoring: {e}")
|
logger.error(f"Error in feed monitoring: {e}")
|
||||||
|
|
||||||
await asyncio.sleep(300)
|
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
||||||
|
|
||||||
def build_application(self):
|
def build_application(self):
|
||||||
"""Build the Telegram application"""
|
"""Build the Telegram application"""
|
||||||
@@ -482,7 +535,7 @@ class ThreatIntelBot:
|
|||||||
# Add command handlers
|
# Add command handlers
|
||||||
self.application.add_handler(CommandHandler("start", self.start_command))
|
self.application.add_handler(CommandHandler("start", self.start_command))
|
||||||
self.application.add_handler(CommandHandler("help", self.help_command))
|
self.application.add_handler(CommandHandler("help", self.help_command))
|
||||||
for category in CATEGORY_CONFIG:
|
for category in ALL_CATEGORIES:
|
||||||
self.application.add_handler(
|
self.application.add_handler(
|
||||||
CommandHandler(f"on_{category}", partial(self.category_on_command, category=category))
|
CommandHandler(f"on_{category}", partial(self.category_on_command, category=category))
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-19
@@ -5,7 +5,6 @@ Review validation results — pretty-prints results.jsonl with optional filters.
|
|||||||
Usage:
|
Usage:
|
||||||
python3 validation/review.py # all results
|
python3 validation/review.py # all results
|
||||||
python3 validation/review.py --category research # filter by category
|
python3 validation/review.py --category research # filter by category
|
||||||
python3 validation/review.py --severity critical # filter by severity
|
|
||||||
python3 validation/review.py --has cves # only articles with CVEs
|
python3 validation/review.py --has cves # only articles with CVEs
|
||||||
python3 validation/review.py --has malware # only articles with malware hits
|
python3 validation/review.py --has malware # only articles with malware hits
|
||||||
python3 validation/review.py --has actors # only articles with threat actors
|
python3 validation/review.py --has actors # only articles with threat actors
|
||||||
@@ -20,13 +19,10 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
RESULTS_FILE = Path(__file__).parent / "results.jsonl"
|
RESULTS_FILE = Path(__file__).parent / "results.jsonl"
|
||||||
|
|
||||||
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unknown": 4}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument("--category", help="Filter by category (news, malware, threat_intel, osint, research)")
|
p.add_argument("--category", help="Filter by category (news, malware, threat_intel, osint, research)")
|
||||||
p.add_argument("--severity", help="Filter by severity (critical, high, medium, low)")
|
|
||||||
p.add_argument("--has", choices=["cves", "malware", "actors"], help="Only show articles with these detections")
|
p.add_argument("--has", choices=["cves", "malware", "actors"], help="Only show articles with these detections")
|
||||||
p.add_argument("--today", action="store_true", help="Only show results from today")
|
p.add_argument("--today", action="store_true", help="Only show results from today")
|
||||||
p.add_argument("--limit", type=int, default=0, help="Max articles to show (0 = all)")
|
p.add_argument("--limit", type=int, default=0, help="Max articles to show (0 = all)")
|
||||||
@@ -55,8 +51,6 @@ def load_results(args):
|
|||||||
continue
|
continue
|
||||||
if args.category and r.get("category") != args.category:
|
if args.category and r.get("category") != args.category:
|
||||||
continue
|
continue
|
||||||
if args.severity and r.get("severity") != args.severity:
|
|
||||||
continue
|
|
||||||
if args.has == "cves" and not r.get("cves"):
|
if args.has == "cves" and not r.get("cves"):
|
||||||
continue
|
continue
|
||||||
if args.has == "malware" and not r.get("malware_families"):
|
if args.has == "malware" and not r.get("malware_families"):
|
||||||
@@ -66,18 +60,13 @@ def load_results(args):
|
|||||||
|
|
||||||
records.append(r)
|
records.append(r)
|
||||||
|
|
||||||
records.sort(key=lambda x: SEVERITY_ORDER.get(x.get("severity", "unknown"), 4))
|
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
def print_record(r):
|
def print_record(r):
|
||||||
sev = r.get("severity", "unknown").upper()
|
|
||||||
sev_icons = {"CRITICAL": "🚨", "HIGH": "🔴", "MEDIUM": "🟠", "LOW": "🟡"}
|
|
||||||
icon = sev_icons.get(sev, "⚪")
|
|
||||||
|
|
||||||
print(f"\n{'─' * 80}")
|
print(f"\n{'─' * 80}")
|
||||||
print(f"{icon} [{r.get('category', '?').upper()}] {r.get('title', 'No title')}")
|
print(f"[{r.get('category', '?').upper()}] {r.get('title', 'No title')}")
|
||||||
print(f" 📡 {r.get('source')} · {r.get('published')} · Score: {r.get('quality_score', '?')}/100")
|
print(f" 📡 {r.get('source')} · {r.get('published')}")
|
||||||
|
|
||||||
if r.get("cves"):
|
if r.get("cves"):
|
||||||
print(f" 🆔 {', '.join(r['cves'])}")
|
print(f" 🆔 {', '.join(r['cves'])}")
|
||||||
@@ -104,13 +93,7 @@ def main():
|
|||||||
records = records[:args.limit]
|
records = records[:args.limit]
|
||||||
|
|
||||||
total = len(records)
|
total = len(records)
|
||||||
sev_counts = {}
|
|
||||||
for r in records:
|
|
||||||
s = r.get("severity", "unknown")
|
|
||||||
sev_counts[s] = sev_counts.get(s, 0) + 1
|
|
||||||
|
|
||||||
print(f"\n=== Validation Results ({total} articles) ===")
|
print(f"\n=== Validation Results ({total} articles) ===")
|
||||||
print(" " + " ".join(f"{s.upper()}: {c}" for s, c in sorted(sev_counts.items(), key=lambda x: SEVERITY_ORDER.get(x[0], 4))))
|
|
||||||
|
|
||||||
for r in records:
|
for r in records:
|
||||||
print_record(r)
|
print_record(r)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import aiohttp
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).parent.parent
|
ROOT = Path(__file__).parent.parent
|
||||||
@@ -33,19 +33,11 @@ if env_file.exists():
|
|||||||
from rss_manager import RSSFeedManager
|
from rss_manager import RSSFeedManager
|
||||||
from content_classifier import ContentClassifier
|
from content_classifier import ContentClassifier
|
||||||
from ransomware_fetcher import RansomwareFetcher
|
from ransomware_fetcher import RansomwareFetcher
|
||||||
|
from bot_config import CATEGORY_FEEDS as CATEGORY_CONFIG, POLL_INTERVAL_SECONDS as POLL_INTERVAL, SEEN_RETENTION_DAYS
|
||||||
CATEGORY_CONFIG = {
|
|
||||||
"news": "feeds/news_feeds.json",
|
|
||||||
"malware": "feeds/malware_feeds.json",
|
|
||||||
"threat_intel": "feeds/threat_intel_feeds.json",
|
|
||||||
"osint": "feeds/osint_feeds.json",
|
|
||||||
"research": "feeds/research_feeds.json",
|
|
||||||
}
|
|
||||||
|
|
||||||
OUTPUT_FILE = Path(__file__).parent / "results.jsonl"
|
OUTPUT_FILE = Path(__file__).parent / "results.jsonl"
|
||||||
SEEN_FILE = str(Path(__file__).parent / "seen_validation.db")
|
SEEN_FILE = str(Path(__file__).parent / "seen_validation.db")
|
||||||
SEEN_VICTIMS_FILE = str(Path(__file__).parent / "seen_validation_victims.db")
|
SEEN_VICTIMS_FILE = str(Path(__file__).parent / "seen_validation_victims.db")
|
||||||
POLL_INTERVAL = 300
|
|
||||||
|
|
||||||
|
|
||||||
def save_record(f, record: dict):
|
def save_record(f, record: dict):
|
||||||
@@ -123,7 +115,7 @@ async def poll_victims(fetcher: RansomwareFetcher) -> int:
|
|||||||
"source": "ransomware.live",
|
"source": "ransomware.live",
|
||||||
"url": article.get("url"),
|
"url": article.get("url"),
|
||||||
"published": article.get("published_human"),
|
"published": article.get("published_human"),
|
||||||
"category": "malware",
|
"category": article.get("category"),
|
||||||
"cves": [],
|
"cves": [],
|
||||||
"threat_actors": [],
|
"threat_actors": [],
|
||||||
"malware_families": article.get("malware_families", []),
|
"malware_families": article.get("malware_families", []),
|
||||||
@@ -135,6 +127,33 @@ async def poll_victims(fetcher: RansomwareFetcher) -> int:
|
|||||||
return len(new_victims)
|
return len(new_victims)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_results_file():
|
||||||
|
"""Keep results.jsonl bounded to the same window dedup actually needs — this is a
|
||||||
|
testing aid, not an archive, so nothing here needs to outlive SEEN_RETENTION_DAYS."""
|
||||||
|
if not OUTPUT_FILE.exists():
|
||||||
|
return
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=SEEN_RETENTION_DAYS)).isoformat()
|
||||||
|
kept = []
|
||||||
|
dropped = 0
|
||||||
|
with open(OUTPUT_FILE) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
record = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if record.get("run_at", "") >= cutoff:
|
||||||
|
kept.append(line)
|
||||||
|
else:
|
||||||
|
dropped += 1
|
||||||
|
if dropped:
|
||||||
|
with open(OUTPUT_FILE, "w") as f:
|
||||||
|
f.write("\n".join(kept) + ("\n" if kept else ""))
|
||||||
|
print(f" Pruned {dropped} record(s) older than {SEEN_RETENTION_DAYS} days")
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
classifier = ContentClassifier()
|
classifier = ContentClassifier()
|
||||||
|
|
||||||
@@ -166,9 +185,11 @@ async def main():
|
|||||||
victim_count = await poll_victims(fetcher) if fetcher else 0
|
victim_count = await poll_victims(fetcher) if fetcher else 0
|
||||||
total = rss_count + victim_count
|
total = rss_count + victim_count
|
||||||
if total:
|
if total:
|
||||||
print(f" Saved {rss_count} articles + {victim_count} victims\n")
|
print(f" Saved {rss_count} articles + {victim_count} victims")
|
||||||
else:
|
else:
|
||||||
print(f" Nothing new\n")
|
print(f" Nothing new")
|
||||||
|
prune_results_file()
|
||||||
|
print()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" Error: {e}\n")
|
print(f" Error: {e}\n")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user