RSS Telegram Bot
This commit is contained in:
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Bot token and sensitive config
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Subscriber and tracking data
|
||||||
|
subscribers.json
|
||||||
|
seen_articles.json
|
||||||
|
seen_articles.db
|
||||||
|
seen_articles.db-journal
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test_*.py
|
||||||
|
*_test.py
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
# Threat Intelligence RSS Telegram Bot
|
||||||
|
|
||||||
|
A sophisticated Telegram bot that monitors cybersecurity RSS feeds and delivers real-time alerts to Telegram chats or topics. Automatically classifies content by severity (critical/high/medium/low), quality, and category (News, Malware, Threat Intel, OSINT, Research).
|
||||||
|
|
||||||
|
**Perfect for:** Security teams, SOC analysts, threat hunters, and cybersecurity enthusiasts who want curated threat intelligence delivered directly to Telegram.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-Category Feeds:** News, Malware, Threat Intel, OSINT, Research
|
||||||
|
- **Telegram Topics Support:** Different categories post to different topics in the same group
|
||||||
|
- **Intelligent Classification:** Auto-classifies articles by severity and quality
|
||||||
|
- **Duplicate Prevention:** Deduplicates articles across feeds and sources
|
||||||
|
- **Quality Filtering:** Filter by minimum quality score and severity levels
|
||||||
|
- **SQLite Tracking:** Persistent tracking of seen articles
|
||||||
|
- **Async Architecture:** High-performance async/await design
|
||||||
|
- **Feed Health Monitoring:** `/stats` command shows feed status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python 3.9+
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install python-telegram-bot feedparser aiohttp beautifulsoup4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Get a Telegram Bot Token
|
||||||
|
|
||||||
|
1. Open Telegram and message [@BotFather](https://t.me/BotFather)
|
||||||
|
2. Send `/newbot`
|
||||||
|
3. Follow prompts to create your bot
|
||||||
|
4. Copy the token (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||||
|
|
||||||
|
### 3. Configure Your Bot
|
||||||
|
|
||||||
|
**Option 1: Environment Variable**
|
||||||
|
```bash
|
||||||
|
export BOT_TOKEN="your_bot_token_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Create `.env` File**
|
||||||
|
```bash
|
||||||
|
# Create .env file in project directory
|
||||||
|
echo 'BOT_TOKEN=your_bot_token_here' > .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run the Bot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 threat_intel_bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
```
|
||||||
|
🚀 Threat Intel Bot is running!
|
||||||
|
📊 0 subscribers loaded
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Start Receiving Alerts
|
||||||
|
|
||||||
|
In Telegram:
|
||||||
|
1. Find your bot (search by the name you gave it)
|
||||||
|
2. Send `/start` to see available commands
|
||||||
|
3. Enable categories you want:
|
||||||
|
- `/on_news` - News feeds
|
||||||
|
- `/on_threat_intel` - Threat intelligence
|
||||||
|
- `/on_malware` - Malware analysis
|
||||||
|
- `/on_osint` - OSINT feeds
|
||||||
|
- `/on_research` - Security research
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
rss/
|
||||||
|
├── threat_intel_bot.py # Main bot (commands, subscriptions, alerts)
|
||||||
|
├── rss_manager.py # RSS feed parsing and article extraction
|
||||||
|
├── content_classifier.py # Article classification (severity/quality)
|
||||||
|
├── check_feeds.py # Utility to check feed health
|
||||||
|
│
|
||||||
|
├── feeds/ # RSS feed configurations
|
||||||
|
│ ├── news_feeds.json # News sources
|
||||||
|
│ ├── malware_feeds.json # Malware analysis blogs
|
||||||
|
│ ├── threat_intel_feeds.json # Threat intelligence feeds
|
||||||
|
│ ├── osint_feeds.json # OSINT resources
|
||||||
|
│ └── research_feeds.json # Security research
|
||||||
|
│
|
||||||
|
├── subscribers.json # Subscriber data (auto-created)
|
||||||
|
└── seen_articles.db # SQLite DB tracking seen articles
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Reference: What to Edit
|
||||||
|
|
||||||
|
| What You Want to Do | File to Edit | Section |
|
||||||
|
|---------------------|--------------|---------|
|
||||||
|
| **Add/remove news feeds** | `feeds/news_feeds.json` | Add URL under category |
|
||||||
|
| **Add/remove threat intel feeds** | `feeds/threat_intel_feeds.json` | Add URL under category |
|
||||||
|
| **Add/remove malware feeds** | `feeds/malware_feeds.json` | Add URL under category |
|
||||||
|
| **Add/remove OSINT feeds** | `feeds/osint_feeds.json` | Add URL under category |
|
||||||
|
| **Add/remove research feeds** | `feeds/research_feeds.json` | Add URL under category |
|
||||||
|
| **Change quality threshold** | `.env` or environment | Set `MIN_QUALITY_SCORE=50` |
|
||||||
|
| **Filter by severity** | `.env` or environment | Set `ALLOWED_SEVERITIES=critical,high` |
|
||||||
|
| **Change polling interval** | `threat_intel_bot.py` | Line 475 (default: 300 seconds) |
|
||||||
|
| **Customize classification rules** | `content_classifier.py` | Keyword dictionaries |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bot Commands
|
||||||
|
|
||||||
|
### User Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/start` | Show welcome message and available commands |
|
||||||
|
| `/help` | Same as `/start` |
|
||||||
|
| `/on_news` | Subscribe to news feeds |
|
||||||
|
| `/on_malware` | Subscribe to malware analysis |
|
||||||
|
| `/on_threat_intel` | Subscribe to threat intelligence |
|
||||||
|
| `/on_osint` | Subscribe to OSINT feeds |
|
||||||
|
| `/on_research` | Subscribe to security research |
|
||||||
|
| `/off_news` | Unsubscribe from news |
|
||||||
|
| `/off_malware` | Unsubscribe from malware |
|
||||||
|
| `/off_threat_intel` | Unsubscribe from threat intel |
|
||||||
|
| `/off_osint` | Unsubscribe from OSINT |
|
||||||
|
| `/off_research` | Unsubscribe from research |
|
||||||
|
| `/stats` | Check feed health and status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using with Telegram Topics (Forum Groups)
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
1. Create a Telegram group
|
||||||
|
2. Enable "Topics" in group settings
|
||||||
|
3. Create topics: "News", "Threat Intel", "Malware", etc.
|
||||||
|
4. Add your bot to the group
|
||||||
|
|
||||||
|
**Subscribe topics to categories:**
|
||||||
|
- Open "News" topic → Send `/on_news`
|
||||||
|
- Open "Threat Intel" topic → Send `/on_threat_intel`
|
||||||
|
- Open "Malware" topic → Send `/on_malware`
|
||||||
|
- Open "OSINT" topic → Send `/on_osint`
|
||||||
|
- Open "Research" topic → Send `/on_research`
|
||||||
|
|
||||||
|
**Result:** Each category posts to its own topic automatically!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Managing RSS Feeds
|
||||||
|
|
||||||
|
### Feed File Structure
|
||||||
|
|
||||||
|
Each feed file (`feeds/*.json`) has this format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"category_name": {
|
||||||
|
"Feed Name": "https://example.com/rss.xml",
|
||||||
|
"Another Feed": "https://another.com/feed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** Add a new threat intel feed
|
||||||
|
|
||||||
|
**File:** `feeds/threat_intel_feeds.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"threat_intel": {
|
||||||
|
"SANS ISC": "https://isc.sans.edu/rssfeed.xml",
|
||||||
|
"Your New Feed": "https://newfeed.com/rss.xml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After editing:** Restart the bot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop: Ctrl+C
|
||||||
|
# Start:
|
||||||
|
python3 threat_intel_bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content Classification
|
||||||
|
|
||||||
|
### How Articles Are Classified
|
||||||
|
|
||||||
|
**Quality Score (0-100):**
|
||||||
|
- High-quality sources (Project Zero, Mandiant): +30 points
|
||||||
|
- Deep dive/technical analysis: +25 points
|
||||||
|
- Proof-of-concept/exploit: +20 points
|
||||||
|
- Research papers: +15 points
|
||||||
|
- Vulnerability advisory: +10 points
|
||||||
|
|
||||||
|
**Severity Levels:**
|
||||||
|
- **Critical:** Zero-days, active exploitation, RCE, ransomware
|
||||||
|
- **High:** Privilege escalation, auth bypass, code execution
|
||||||
|
- **Medium:** XSS, CSRF, information disclosure, DoS
|
||||||
|
- **Low:** Everything else
|
||||||
|
|
||||||
|
### Filtering Articles
|
||||||
|
|
||||||
|
**By Quality Score:**
|
||||||
|
```bash
|
||||||
|
# Only show high-quality articles (score 50+)
|
||||||
|
export MIN_QUALITY_SCORE=50
|
||||||
|
python3 threat_intel_bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**By Severity:**
|
||||||
|
```bash
|
||||||
|
# Only critical and high severity
|
||||||
|
export ALLOWED_SEVERITIES="critical,high"
|
||||||
|
python3 threat_intel_bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Both:**
|
||||||
|
```bash
|
||||||
|
export MIN_QUALITY_SCORE=40
|
||||||
|
export ALLOWED_SEVERITIES="critical,high,medium"
|
||||||
|
python3 threat_intel_bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
|
||||||
|
# Optional filters
|
||||||
|
MIN_QUALITY_SCORE=0 # 0-100 (default: 0 = no filtering)
|
||||||
|
ALLOWED_SEVERITIES=critical,high,medium,low # Comma-separated
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking Feed Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if all feeds are working
|
||||||
|
python3 check_feeds.py
|
||||||
|
|
||||||
|
# Or use the bot command
|
||||||
|
# In Telegram: /stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output shows:**
|
||||||
|
- ✅ Online feeds (with article count)
|
||||||
|
- ❌ Offline/broken feeds (with error)
|
||||||
|
- Last update time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running as a Service (Linux)
|
||||||
|
|
||||||
|
### Systemd Service
|
||||||
|
|
||||||
|
**1. Create service file:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/threat-intel-bot.service
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Add this content:**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Threat Intelligence RSS Telegram Bot
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=your_username
|
||||||
|
WorkingDirectory=/home/your_username/Documents/telegram-bots/rss
|
||||||
|
Environment="BOT_TOKEN=your_bot_token_here"
|
||||||
|
ExecStart=/usr/bin/python3 /home/your_username/Documents/telegram-bots/rss/threat_intel_bot.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Enable and start:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable threat-intel-bot
|
||||||
|
sudo systemctl start threat-intel-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Check status:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status threat-intel-bot
|
||||||
|
sudo journalctl -u threat-intel-bot -f # View logs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running with Docker (Optional)
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install python-telegram-bot feedparser aiohttp beautifulsoup4
|
||||||
|
|
||||||
|
# Copy bot files
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
# Run bot
|
||||||
|
CMD ["python3", "threat_intel_bot.py"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
threat-intel-bot:
|
||||||
|
build: .
|
||||||
|
container_name: threat-intel-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- BOT_TOKEN=${BOT_TOKEN}
|
||||||
|
- MIN_QUALITY_SCORE=${MIN_QUALITY_SCORE:-0}
|
||||||
|
- ALLOWED_SEVERITIES=${ALLOWED_SEVERITIES:-critical,high,medium,low}
|
||||||
|
volumes:
|
||||||
|
- ./subscribers.json:/app/subscribers.json
|
||||||
|
- ./seen_articles.db:/app/seen_articles.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create .env file first
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Add Your Own Feed Categories
|
||||||
|
|
||||||
|
**1. Edit `threat_intel_bot.py`:**
|
||||||
|
|
||||||
|
Find `CATEGORY_CONFIG` (line 37):
|
||||||
|
|
||||||
|
```python
|
||||||
|
CATEGORY_CONFIG = {
|
||||||
|
"news": {"label": "News", "emoji": "📰", "feeds_file": "feeds/news_feeds.json"},
|
||||||
|
"malware": {"label": "Malware", "emoji": "🦠", "feeds_file": "feeds/malware_feeds.json"},
|
||||||
|
# Add new category:
|
||||||
|
"your_category": {"label": "Your Category", "emoji": "🔥", "feeds_file": "feeds/your_feeds.json"},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create feed file:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano feeds/your_feeds.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"your_category": {
|
||||||
|
"Feed Name 1": "https://example.com/rss.xml",
|
||||||
|
"Feed Name 2": "https://another.com/feed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Restart bot**
|
||||||
|
|
||||||
|
Users can now use `/on_your_category` and `/off_your_category`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Customize Classification Keywords
|
||||||
|
|
||||||
|
**File:** `content_classifier.py`
|
||||||
|
|
||||||
|
**Example:** Add new critical keywords
|
||||||
|
|
||||||
|
```python
|
||||||
|
CRITICAL_KEYWORDS = {
|
||||||
|
'zero-day', '0day', 'zero day',
|
||||||
|
# Add your keywords:
|
||||||
|
'your_critical_term',
|
||||||
|
'another_urgent_keyword',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** Add high-quality sources
|
||||||
|
|
||||||
|
```python
|
||||||
|
HIGH_QUALITY_SOURCES = {
|
||||||
|
'watchTowr Labs', 'Doyensec Blog',
|
||||||
|
# Add your sources (exact name from feed):
|
||||||
|
'Your Favorite Security Blog',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Bot Not Responding
|
||||||
|
|
||||||
|
**Check if running:**
|
||||||
|
```bash
|
||||||
|
ps aux | grep threat_intel_bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**View logs:**
|
||||||
|
```bash
|
||||||
|
# If running as service
|
||||||
|
sudo journalctl -u threat-intel-bot -f
|
||||||
|
|
||||||
|
# If running manually, check terminal output
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### No Alerts Received
|
||||||
|
|
||||||
|
**1. Check subscription:**
|
||||||
|
```
|
||||||
|
In Telegram: /stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Should show you're subscribed.
|
||||||
|
|
||||||
|
**2. Check feeds are working:**
|
||||||
|
```bash
|
||||||
|
python3 check_feeds.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Check quality/severity filters:**
|
||||||
|
- If `MIN_QUALITY_SCORE=80`, only very high-quality articles pass
|
||||||
|
- If `ALLOWED_SEVERITIES=critical`, only critical alerts show
|
||||||
|
|
||||||
|
**Lower thresholds:**
|
||||||
|
```bash
|
||||||
|
export MIN_QUALITY_SCORE=0
|
||||||
|
export ALLOWED_SEVERITIES="critical,high,medium,low"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feed Parse Errors
|
||||||
|
|
||||||
|
**Symptom:** Logs show "Error fetching feed"
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Feed URL is broken/changed
|
||||||
|
- Feed server is down
|
||||||
|
- Network connectivity issue
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Check feed URL in browser
|
||||||
|
2. Update URL in `feeds/*.json` if changed
|
||||||
|
3. Remove dead feeds
|
||||||
|
4. Restart bot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SQLite Database Issues
|
||||||
|
|
||||||
|
**Reset seen articles (get all articles again):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop bot
|
||||||
|
rm seen_articles.db
|
||||||
|
|
||||||
|
# Restart bot (will recreate DB)
|
||||||
|
python3 threat_intel_bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** First run marks all existing articles as seen (no alerts). New articles after that trigger alerts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Files
|
||||||
|
|
||||||
|
### subscribers.json
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subscribers": {
|
||||||
|
"123456789": {
|
||||||
|
"topic_id": null,
|
||||||
|
"feed_types": ["news", "threat_intel"]
|
||||||
|
},
|
||||||
|
"987654321_12345": {
|
||||||
|
"topic_id": 12345,
|
||||||
|
"feed_types": ["malware"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Explanation:**
|
||||||
|
- Key format: `chat_id` or `chat_id_topic_id`
|
||||||
|
- `topic_id`: null for private/group, number for forum topics
|
||||||
|
- `feed_types`: Array of subscribed categories
|
||||||
|
|
||||||
|
**Manual editing:** You can edit this file, but bot does it automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### seen_articles.db
|
||||||
|
|
||||||
|
**SQLite database tracking processed articles.**
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE seen_articles (
|
||||||
|
article_key TEXT PRIMARY KEY,
|
||||||
|
seen_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**View contents:**
|
||||||
|
```bash
|
||||||
|
sqlite3 seen_articles.db "SELECT * FROM seen_articles LIMIT 10;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Start with Default Filters
|
||||||
|
Don't set quality/severity filters initially - see what you get first, then filter.
|
||||||
|
|
||||||
|
### 2. Use Topics for Organization
|
||||||
|
If using in a group, enable Topics and route each category to its topic.
|
||||||
|
|
||||||
|
### 3. Monitor Feed Health
|
||||||
|
Run `/stats` weekly to check for broken feeds.
|
||||||
|
|
||||||
|
### 4. Curate Your Feeds
|
||||||
|
Start with defaults, add/remove based on signal-to-noise ratio.
|
||||||
|
|
||||||
|
### 5. Run as a Service
|
||||||
|
Use systemd so bot survives reboots and crashes.
|
||||||
|
|
||||||
|
### 6. Backup Subscriber Data
|
||||||
|
```bash
|
||||||
|
cp subscribers.json subscribers.backup.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feed Categories Explained
|
||||||
|
|
||||||
|
### News Feeds (`feeds/news_feeds.json`)
|
||||||
|
Daily cybersecurity news, breach announcements, general updates.
|
||||||
|
- **Update frequency:** Multiple times per day
|
||||||
|
- **Volume:** High
|
||||||
|
- **Use case:** Stay informed on current events
|
||||||
|
|
||||||
|
### Malware Feeds (`feeds/malware_feeds.json`)
|
||||||
|
Malware analysis, reverse engineering blogs, threat reports.
|
||||||
|
- **Update frequency:** Daily to weekly
|
||||||
|
- **Volume:** Medium
|
||||||
|
- **Use case:** Malware research, threat hunting
|
||||||
|
|
||||||
|
### Threat Intel Feeds (`feeds/threat_intel_feeds.json`)
|
||||||
|
Vendor threat intelligence, APT reports, threat actor profiles.
|
||||||
|
- **Update frequency:** Daily to weekly
|
||||||
|
- **Volume:** Medium
|
||||||
|
- **Use case:** Threat intelligence, SOC operations
|
||||||
|
|
||||||
|
### OSINT Feeds (`feeds/osint_feeds.json`)
|
||||||
|
Open-source intelligence, tools, techniques, investigations.
|
||||||
|
- **Update frequency:** Weekly
|
||||||
|
- **Volume:** Low to medium
|
||||||
|
- **Use case:** OSINT research, investigative work
|
||||||
|
|
||||||
|
### Research Feeds (`feeds/research_feeds.json`)
|
||||||
|
Deep technical research, vulnerability analysis, exploit development.
|
||||||
|
- **Update frequency:** Weekly to monthly
|
||||||
|
- **Volume:** Low (high quality)
|
||||||
|
- **Use case:** Learning, in-depth technical knowledge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing Feeds
|
||||||
|
|
||||||
|
**Want to add a good feed?** Edit the appropriate JSON file and submit a pull request or update your fork.
|
||||||
|
|
||||||
|
**Criteria for good feeds:**
|
||||||
|
- Reliable RSS/Atom feed
|
||||||
|
- Regular updates (at least monthly)
|
||||||
|
- Quality content (no spam/clickbait)
|
||||||
|
- Relevant to cybersecurity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
**Typical resource usage:**
|
||||||
|
- **CPU:** <5% (idle), 10-20% during feed fetch
|
||||||
|
- **RAM:** ~50-100MB
|
||||||
|
- **Network:** Minimal (fetches feeds every 5 minutes)
|
||||||
|
- **Disk:** ~10MB (grows slowly with seen articles DB)
|
||||||
|
|
||||||
|
**Scaling:**
|
||||||
|
- Tested with 50+ feeds
|
||||||
|
- Handles 100+ subscribers
|
||||||
|
- Processes ~500 articles/day
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Bot Token
|
||||||
|
- **Never commit** `.env` file to Git
|
||||||
|
- Treat bot token like a password
|
||||||
|
- Regenerate if leaked (via @BotFather)
|
||||||
|
|
||||||
|
### Network Access
|
||||||
|
- Bot fetches public RSS feeds (outbound HTTPS)
|
||||||
|
- Telegram API (outbound HTTPS)
|
||||||
|
- No inbound connections needed
|
||||||
|
|
||||||
|
### Data Privacy
|
||||||
|
- Subscriber data stored locally in `subscribers.json`
|
||||||
|
- No data sent to third parties
|
||||||
|
- Article URLs/metadata only (no personal data)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: How often does the bot check feeds?**
|
||||||
|
A: Every 5 minutes (configurable in `threat_intel_bot.py` line 475)
|
||||||
|
|
||||||
|
**Q: Can I run multiple bots from same code?**
|
||||||
|
A: Yes, just use different bot tokens and run in separate directories
|
||||||
|
|
||||||
|
**Q: Does it support private feeds?**
|
||||||
|
A: No, only public RSS feeds. For private feeds, you'd need to add authentication
|
||||||
|
|
||||||
|
**Q: Can I export articles to a database?**
|
||||||
|
A: Articles are in `seen_articles.db` (SQLite). You can query it or extend the code
|
||||||
|
|
||||||
|
**Q: Why no alerts on first run?**
|
||||||
|
A: First run marks existing articles as seen to avoid spam. Only new articles after that trigger alerts
|
||||||
|
|
||||||
|
**Q: Can I get alerts in multiple languages?**
|
||||||
|
A: Bot messages are in English. Articles are in whatever language the feed provides
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is open source. Use it for personal or commercial purposes.
|
||||||
|
|
||||||
|
No warranty provided. Use at your own risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Built with:
|
||||||
|
- [python-telegram-bot](https://python-telegram-bot.org/) - Telegram Bot API wrapper
|
||||||
|
- [feedparser](https://feedparser.readthedocs.io/) - RSS/Atom feed parser
|
||||||
|
- [aiohttp](https://docs.aiohttp.org/) - Async HTTP client
|
||||||
|
- [BeautifulSoup4](https://www.crummy.com/software/BeautifulSoup/) - HTML parsing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Maintained for personal use - built for the cybersecurity community.**
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Quick script to check all RSS feeds status
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from rss_manager import RSSFeedManager
|
||||||
|
|
||||||
|
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():
|
||||||
|
totals = {}
|
||||||
|
offline_by_category = {}
|
||||||
|
|
||||||
|
for category, feeds_file in CATEGORY_FEEDS.items():
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print(f"CHECKING {category.upper()} FEEDS")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
async with RSSFeedManager(feeds_file=feeds_file, feed_type=category) as manager:
|
||||||
|
status_data = await manager.check_feed_status()
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
online = 0
|
||||||
|
offline = []
|
||||||
|
|
||||||
|
for status_category, feeds in status_data.items():
|
||||||
|
print(f"\n📡 {status_category.replace('_', ' ').title()}:")
|
||||||
|
for feed_name, feed_info in feeds.items():
|
||||||
|
total += 1
|
||||||
|
status_icon = feed_info['status']
|
||||||
|
entries = feed_info['entries']
|
||||||
|
|
||||||
|
if '✅' in status_icon:
|
||||||
|
online += 1
|
||||||
|
print(f" ✅ {feed_name}: {entries} articles")
|
||||||
|
else:
|
||||||
|
offline.append(feed_name)
|
||||||
|
error = feed_info.get('error', 'Unknown error')
|
||||||
|
print(f" ❌ {feed_name}: {error[:80]}")
|
||||||
|
|
||||||
|
totals[category] = {"total": total, "online": online, "offline": len(offline)}
|
||||||
|
offline_by_category[category] = offline
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("SUMMARY")
|
||||||
|
print("=" * 80)
|
||||||
|
grand_total = 0
|
||||||
|
grand_online = 0
|
||||||
|
grand_offline = 0
|
||||||
|
|
||||||
|
for category in CATEGORY_FEEDS:
|
||||||
|
stats = totals.get(category, {"total": 0, "online": 0, "offline": 0})
|
||||||
|
grand_total += stats["total"]
|
||||||
|
grand_online += stats["online"]
|
||||||
|
grand_offline += stats["offline"]
|
||||||
|
print(f"\n• {category}: total={stats['total']} online={stats['online']} offline={stats['offline']}")
|
||||||
|
|
||||||
|
print(f"\n🌍 OVERALL:")
|
||||||
|
print(f" Total: {grand_total}")
|
||||||
|
print(f" Online: {grand_online} ✅")
|
||||||
|
print(f" Offline: {grand_offline} ❌")
|
||||||
|
|
||||||
|
if grand_offline:
|
||||||
|
print(f"\n⚠️ OFFLINE FEEDS:")
|
||||||
|
for category, feeds in offline_by_category.items():
|
||||||
|
for feed in feeds:
|
||||||
|
print(f" [{category}] {feed}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(check_all_feeds())
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Content Classifier for Threat Intelligence RSS Bot
|
||||||
|
Classifies articles by type, severity, and quality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Set
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ContentClassifier:
|
||||||
|
"""Intelligent content classification for cybersecurity articles"""
|
||||||
|
|
||||||
|
# High-quality research sources (get automatic quality boost)
|
||||||
|
HIGH_QUALITY_SOURCES = {
|
||||||
|
'watchTowr Labs', 'Doyensec Blog', 'Google Project Zero',
|
||||||
|
'GitHub Security Lab', 'Meta Red Team X', 'Aleph Research - Posts',
|
||||||
|
'Mandiant', 'Microsoft Security', 'Embrace The Red'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keywords for deep dive / technical analysis detection
|
||||||
|
DEEP_DIVE_KEYWORDS = {
|
||||||
|
'deep dive', 'technical analysis', 'reverse engineering', 'reversing',
|
||||||
|
'exploitation', 'vulnerability research', 'in-depth', 'comprehensive analysis',
|
||||||
|
'detailed analysis', 'root cause analysis', 'rca', 'technical deep-dive',
|
||||||
|
'vulnerability analysis', 'security research', 'research paper',
|
||||||
|
'whitepaper', 'technical report', 'forensic analysis', 'post-mortem'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keywords for Proof of Concept / Exploit detection
|
||||||
|
POC_KEYWORDS = {
|
||||||
|
'poc', 'proof of concept', 'proof-of-concept', 'exploit code',
|
||||||
|
'exploit released', 'weaponized', 'exploit available', 'working exploit',
|
||||||
|
'public exploit', 'exploit poc', 'exploit demo', 'demonstration',
|
||||||
|
'exploit details', 'attack code', 'sample code', 'exploit published',
|
||||||
|
'exploit development', 'exploit chain', 'exploit technique'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keywords for vulnerability/advisory detection
|
||||||
|
VULNERABILITY_KEYWORDS = {
|
||||||
|
'vulnerability', 'vulnerabilities', 'security flaw', 'security bug',
|
||||||
|
'advisory', 'security advisory', 'patch', 'update', 'hotfix',
|
||||||
|
'security update', 'bug fix', 'disclosure', 'responsible disclosure',
|
||||||
|
'coordinated disclosure', 'full disclosure'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keywords for research papers/academic content
|
||||||
|
RESEARCH_KEYWORDS = {
|
||||||
|
'paper', 'research', 'study', 'findings', 'methodology', 'academic',
|
||||||
|
'conference', 'presentation', 'talk', 'blackhat', 'defcon', 'rsa',
|
||||||
|
'syscan', 'infiltrate', 'recon', 'pwn2own'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Critical severity indicators
|
||||||
|
CRITICAL_KEYWORDS = {
|
||||||
|
'zero-day', '0day', 'zero day', 'actively exploited', 'active exploitation',
|
||||||
|
'in the wild', 'itw', 'critical vulnerability', 'remote code execution', 'rce',
|
||||||
|
'unauthenticated rce', 'pre-auth', 'wormable', 'internet-facing',
|
||||||
|
'mass exploitation', 'ransomware', 'supply chain', 'widespread'
|
||||||
|
}
|
||||||
|
|
||||||
|
# High severity indicators
|
||||||
|
HIGH_KEYWORDS = {
|
||||||
|
'privilege escalation', 'privesc', 'authentication bypass', 'auth bypass',
|
||||||
|
'arbitrary code execution', 'code execution', 'sandbox escape',
|
||||||
|
'kernel exploit', 'local privilege escalation', 'lpe', 'arbitrary file write',
|
||||||
|
'arbitrary file read', 'path traversal', 'directory traversal',
|
||||||
|
'sql injection', 'sqli', 'command injection', 'deserialization'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Medium severity indicators
|
||||||
|
MEDIUM_KEYWORDS = {
|
||||||
|
'cross-site scripting', 'xss', 'csrf', 'cross-site request forgery',
|
||||||
|
'information disclosure', 'data leak', 'sensitive information',
|
||||||
|
'denial of service', 'dos', 'memory corruption', 'use after free',
|
||||||
|
'buffer overflow', 'heap overflow', 'stack overflow'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Threat actor names (common APT groups)
|
||||||
|
THREAT_ACTORS = {
|
||||||
|
'apt1', 'apt28', 'apt29', 'apt32', 'apt33', 'apt34', 'apt35', 'apt37', 'apt38', 'apt39', 'apt40', 'apt41',
|
||||||
|
'lazarus', 'kimsuky', 'andariel', 'fancy bear', 'cozy bear', 'sandworm',
|
||||||
|
'turla', 'equation group', 'carbanak', 'fin7', 'fin6', 'fin8',
|
||||||
|
'conti', 'lockbit', 'blackcat', 'alphv', 'cl0p', 'clop', 'revil', 'darkside',
|
||||||
|
'nobelium', 'hafnium', 'phosphorus', 'holmium', 'strontium',
|
||||||
|
'volt typhoon', 'flax typhoon', 'mustang panda', 'winnti'
|
||||||
|
}
|
||||||
|
|
||||||
|
# MITRE ATT&CK technique pattern
|
||||||
|
MITRE_PATTERN = re.compile(r'T\d{4}(?:\.\d{3})?', re.IGNORECASE)
|
||||||
|
|
||||||
|
# CVE pattern
|
||||||
|
CVE_PATTERN = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE)
|
||||||
|
|
||||||
|
# IOC patterns
|
||||||
|
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 __init__(self):
|
||||||
|
"""Initialize classifier"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def extract_cves(self, text: str) -> List[str]:
|
||||||
|
"""Extract CVE identifiers from text"""
|
||||||
|
cves = self.CVE_PATTERN.findall(text)
|
||||||
|
return list(set([cve.upper() for cve in cves]))
|
||||||
|
|
||||||
|
def extract_mitre_techniques(self, text: str) -> List[str]:
|
||||||
|
"""Extract MITRE ATT&CK technique IDs from text"""
|
||||||
|
techniques = self.MITRE_PATTERN.findall(text)
|
||||||
|
return list(set([t.upper() for t in techniques]))
|
||||||
|
|
||||||
|
def extract_threat_actors(self, text: str) -> List[str]:
|
||||||
|
"""Extract known threat actor names from text"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
found_actors = []
|
||||||
|
for actor in self.THREAT_ACTORS:
|
||||||
|
if actor in text_lower:
|
||||||
|
found_actors.append(actor.upper())
|
||||||
|
return list(set(found_actors))
|
||||||
|
|
||||||
|
def extract_iocs(self, text: str) -> Dict[str, List[str]]:
|
||||||
|
"""Extract Indicators of Compromise from text"""
|
||||||
|
iocs = {
|
||||||
|
'ips': [],
|
||||||
|
'domains': [],
|
||||||
|
'md5': [],
|
||||||
|
'sha1': [],
|
||||||
|
'sha256': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract IPs (basic validation)
|
||||||
|
potential_ips = self.IP_PATTERN.findall(text)
|
||||||
|
for ip in potential_ips:
|
||||||
|
octets = [int(x) for x in ip.split('.')]
|
||||||
|
if all(0 <= x <= 255 for x in octets):
|
||||||
|
iocs['ips'].append(ip)
|
||||||
|
|
||||||
|
# Extract hashes
|
||||||
|
iocs['md5'] = self.HASH_MD5_PATTERN.findall(text)
|
||||||
|
iocs['sha1'] = self.HASH_SHA1_PATTERN.findall(text)
|
||||||
|
iocs['sha256'] = self.HASH_SHA256_PATTERN.findall(text)
|
||||||
|
|
||||||
|
# Extract domains
|
||||||
|
iocs['domains'] = [match.group(0).lower() for match in self.DOMAIN_PATTERN.finditer(text)]
|
||||||
|
|
||||||
|
# Remove duplicates
|
||||||
|
for key in iocs:
|
||||||
|
iocs[key] = list(set(iocs[key]))[:5] # Limit to 5 per type
|
||||||
|
|
||||||
|
return iocs
|
||||||
|
|
||||||
|
def count_keywords(self, text: str, keywords: Set[str]) -> int:
|
||||||
|
"""Count keyword matches in text (case-insensitive)"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
count = 0
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword in text_lower:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def detect_severity(self, title: str, description: str, cves: List[str]) -> str:
|
||||||
|
"""Detect article severity level"""
|
||||||
|
combined = f"{title} {description}".lower()
|
||||||
|
|
||||||
|
# Critical: Zero-days, active exploitation, RCE
|
||||||
|
if self.count_keywords(combined, self.CRITICAL_KEYWORDS) > 0:
|
||||||
|
return 'critical'
|
||||||
|
|
||||||
|
# High: Privilege escalation, auth bypass, code execution
|
||||||
|
if self.count_keywords(combined, self.HIGH_KEYWORDS) > 0:
|
||||||
|
return 'high'
|
||||||
|
|
||||||
|
# Medium: XSS, CSRF, DoS, memory corruption
|
||||||
|
if self.count_keywords(combined, self.MEDIUM_KEYWORDS) > 0:
|
||||||
|
return 'medium'
|
||||||
|
|
||||||
|
# High if multiple CVEs mentioned (likely important)
|
||||||
|
if len(cves) >= 3:
|
||||||
|
return 'high'
|
||||||
|
|
||||||
|
# Default to low
|
||||||
|
return 'low'
|
||||||
|
|
||||||
|
def classify_content_type(self, title: str, description: str, source: str, content_length: int) -> List[str]:
|
||||||
|
"""Classify article content type"""
|
||||||
|
combined = f"{title} {description}".lower()
|
||||||
|
classifications = []
|
||||||
|
|
||||||
|
# Deep Dive detection
|
||||||
|
deep_dive_score = self.count_keywords(combined, self.DEEP_DIVE_KEYWORDS)
|
||||||
|
if deep_dive_score >= 2 or content_length > 1000:
|
||||||
|
classifications.append('deep_dive')
|
||||||
|
|
||||||
|
# PoC detection
|
||||||
|
poc_score = self.count_keywords(combined, self.POC_KEYWORDS)
|
||||||
|
if poc_score >= 1:
|
||||||
|
classifications.append('poc')
|
||||||
|
|
||||||
|
# Research paper detection
|
||||||
|
research_score = self.count_keywords(combined, self.RESEARCH_KEYWORDS)
|
||||||
|
if research_score >= 2:
|
||||||
|
classifications.append('research')
|
||||||
|
|
||||||
|
# Vulnerability/Advisory detection
|
||||||
|
vuln_score = self.count_keywords(combined, self.VULNERABILITY_KEYWORDS)
|
||||||
|
if vuln_score >= 1:
|
||||||
|
classifications.append('advisory')
|
||||||
|
|
||||||
|
# Default to news if no specific classification
|
||||||
|
if not classifications:
|
||||||
|
classifications.append('news')
|
||||||
|
|
||||||
|
return classifications
|
||||||
|
|
||||||
|
def calculate_quality_score(self, article: Dict, classifications: List[str],
|
||||||
|
severity: str, cves: List[str], threat_actors: List[str]) -> int:
|
||||||
|
"""Calculate article quality score (0-100)"""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Base score
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Source reputation boost
|
||||||
|
if article['source'] in self.HIGH_QUALITY_SOURCES:
|
||||||
|
score += 20
|
||||||
|
|
||||||
|
# Content length scoring
|
||||||
|
content_length = len(article.get('description', ''))
|
||||||
|
if content_length > 1000:
|
||||||
|
score += 15
|
||||||
|
elif content_length > 500:
|
||||||
|
score += 10
|
||||||
|
elif content_length > 200:
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
# Classification scoring
|
||||||
|
if 'deep_dive' in classifications:
|
||||||
|
score += 20
|
||||||
|
if 'poc' in classifications:
|
||||||
|
score += 25
|
||||||
|
if 'research' in classifications:
|
||||||
|
score += 15
|
||||||
|
if 'advisory' in classifications:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Severity scoring
|
||||||
|
severity_scores = {
|
||||||
|
'critical': 30,
|
||||||
|
'high': 20,
|
||||||
|
'medium': 10,
|
||||||
|
'low': 5
|
||||||
|
}
|
||||||
|
score += severity_scores.get(severity, 0)
|
||||||
|
|
||||||
|
# CVE presence
|
||||||
|
if cves:
|
||||||
|
score += min(len(cves) * 5, 15) # Max 15 points for CVEs
|
||||||
|
|
||||||
|
# Threat actor mention
|
||||||
|
if threat_actors:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Cap at 100
|
||||||
|
return min(score, 100)
|
||||||
|
|
||||||
|
def classify_article(self, article: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Classify an article and add classification metadata
|
||||||
|
|
||||||
|
Returns: Article dict with added classification fields
|
||||||
|
"""
|
||||||
|
title = article.get('title', '')
|
||||||
|
description = article.get('description', '')
|
||||||
|
source = article.get('source', '')
|
||||||
|
combined_text = f"{title} {description}"
|
||||||
|
|
||||||
|
# Extract entities
|
||||||
|
cves = self.extract_cves(combined_text)
|
||||||
|
mitre_techniques = self.extract_mitre_techniques(combined_text)
|
||||||
|
threat_actors = self.extract_threat_actors(combined_text)
|
||||||
|
iocs = self.extract_iocs(combined_text)
|
||||||
|
|
||||||
|
# Classify content type
|
||||||
|
content_length = len(description)
|
||||||
|
classifications = self.classify_content_type(title, description, source, content_length)
|
||||||
|
|
||||||
|
# Detect severity
|
||||||
|
severity = self.detect_severity(title, description, cves)
|
||||||
|
|
||||||
|
# Calculate quality score
|
||||||
|
quality_score = self.calculate_quality_score(
|
||||||
|
article, classifications, severity, cves, threat_actors
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add classification data to article
|
||||||
|
article['classifications'] = classifications
|
||||||
|
article['severity'] = severity
|
||||||
|
article['quality_score'] = quality_score
|
||||||
|
article['cves'] = cves
|
||||||
|
article['mitre_techniques'] = mitre_techniques
|
||||||
|
article['threat_actors'] = threat_actors
|
||||||
|
article['iocs'] = iocs
|
||||||
|
|
||||||
|
# Log classification
|
||||||
|
logger.info(
|
||||||
|
f"Classified: {article['title'][:50]}... | "
|
||||||
|
f"Score: {quality_score} | Severity: {severity} | "
|
||||||
|
f"Types: {', '.join(classifications)} | CVEs: {len(cves)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return article
|
||||||
|
|
||||||
|
def filter_by_quality(self, articles: List[Dict], min_score: int = 0) -> List[Dict]:
|
||||||
|
"""Filter articles by minimum quality score"""
|
||||||
|
return [a for a in articles if a.get('quality_score', 0) >= min_score]
|
||||||
|
|
||||||
|
def filter_by_severity(self, articles: List[Dict], severities: List[str]) -> List[Dict]:
|
||||||
|
"""Filter articles by severity levels"""
|
||||||
|
return [a for a in articles if a.get('severity') in severities]
|
||||||
|
|
||||||
|
def filter_by_classification(self, articles: List[Dict], classifications: List[str]) -> List[Dict]:
|
||||||
|
"""Filter articles by classification types"""
|
||||||
|
return [
|
||||||
|
a for a in articles
|
||||||
|
if any(c in a.get('classifications', []) for c in classifications)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Example usage and testing
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test article
|
||||||
|
test_article = {
|
||||||
|
'title': 'Zero-Day RCE in Apache Struts: Deep Dive Technical Analysis with PoC',
|
||||||
|
'description': 'This technical deep-dive provides a comprehensive analysis of CVE-2024-12345, '
|
||||||
|
'a critical zero-day remote code execution vulnerability in Apache Struts. '
|
||||||
|
'We present a working proof-of-concept exploit and reverse engineering of the patch. '
|
||||||
|
'The vulnerability was exploited by APT28 in the wild. Sample hash: '
|
||||||
|
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||||
|
'source': 'Google Project Zero',
|
||||||
|
'url': 'https://example.com',
|
||||||
|
'category': 'zero_day_feeds',
|
||||||
|
'published': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'published_human': 'test'
|
||||||
|
}
|
||||||
|
|
||||||
|
classifier = ContentClassifier()
|
||||||
|
classified = classifier.classify_article(test_article)
|
||||||
|
|
||||||
|
print("Classification Results:")
|
||||||
|
print(f"Title: {classified['title']}")
|
||||||
|
print(f"Quality Score: {classified['quality_score']}/100")
|
||||||
|
print(f"Severity: {classified['severity']}")
|
||||||
|
print(f"Classifications: {', '.join(classified['classifications'])}")
|
||||||
|
print(f"CVEs: {classified['cves']}")
|
||||||
|
print(f"MITRE Techniques: {classified['mitre_techniques']}")
|
||||||
|
print(f"Threat Actors: {classified['threat_actors']}")
|
||||||
|
print(f"IOCs: {classified['iocs']}")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"malware": {
|
||||||
|
"Malware Traffic Analysis": "http://www.malware-traffic-analysis.net/blog-entries.rss",
|
||||||
|
"Security Affairs": "https://securityaffairs.com/feed",
|
||||||
|
"MalwareMustDie": "https://malwaremustdie.blogspot.com/feeds/posts/default",
|
||||||
|
"Kaspersky Securelist": "https://securelist.com/en/rss/allupdates",
|
||||||
|
"Sekoia.io Blog": "https://blog.sekoia.io/feed",
|
||||||
|
"FeedSpot - Malware RSS": "https://rss.feedspot.com/malware_rss_feeds/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"news": {
|
||||||
|
"Krebs on Security": "https://krebsonsecurity.com/feed/",
|
||||||
|
"Bleeping Computer": "https://www.bleepingcomputer.com/feed/",
|
||||||
|
"The Hacker News": "http://feeds.feedburner.com/TheHackersNews",
|
||||||
|
"Security Week": "https://www.securityweek.com/feed",
|
||||||
|
"Dark Reading": "https://www.darkreading.com/rss.xml",
|
||||||
|
"Threatpost": "https://threatpost.com/feed/",
|
||||||
|
"Cyberscoop": "https://www.cyberscoop.com/feed/",
|
||||||
|
"The Record (Recorded Future)": "https://therecord.media/feed",
|
||||||
|
"Cybercrime Magazine": "https://cybersecurityventures.com/feed",
|
||||||
|
"Security Magazine": "https://www.securitymagazine.com/rss",
|
||||||
|
"Cyber Security Hub": "https://www.cshub.com/rss-feeds",
|
||||||
|
"PortSwigger Daily Swig": "https://portswigger.net/daily-swig/rss",
|
||||||
|
"Qualys": "https://blog.qualys.com/feed",
|
||||||
|
"Rapid7": "https://blog.rapid7.com/feed",
|
||||||
|
"0x44 Security Blog": "https://0x44.cc/feed.xml",
|
||||||
|
"NCSC UK Blog": "https://www.ncsc.gov.uk/api/1/services/v1/all-rss-feed.xml",
|
||||||
|
"FBI Cyber Feeds": "https://www.fbi.gov/feeds",
|
||||||
|
"CERT.LV": "https://cert.lv/en/feed/rss/all"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"osint": {
|
||||||
|
"DataBreaches.net": "https://databreaches.net/feed/",
|
||||||
|
"UpGuard - Breaches": "https://www.upguard.com/breaches/rss.xml",
|
||||||
|
"UpGuard - News": "https://www.upguard.com/news/rss.xml",
|
||||||
|
"Vulmon Research": "https://research.vulmon.com/feed",
|
||||||
|
"Have I Been Pwned": "http://feeds.feedburner.com/HaveIBeenPwnedLatestBreaches"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"research": {
|
||||||
|
"Aleph Research - Posts": "https://alephsecurity.com/feed.xml",
|
||||||
|
"Doyensec Blog": "https://blog.doyensec.com/atom.xml",
|
||||||
|
"Embrace The Red": "https://embracethered.com/blog/index.xml",
|
||||||
|
"GitHub Security Lab": "https://github.blog/tag/github-security-lab/feed/",
|
||||||
|
"Meta Red Team X": "https://rtx.meta.security/feed.xml",
|
||||||
|
"Mozilla Attack & Defense": "https://blog.mozilla.org/attack-and-defense/feed/",
|
||||||
|
"watchTowr Labs": "https://labs.watchtowr.com/rss/",
|
||||||
|
"Google Security Blog": "https://security.googleblog.com/feeds/posts/default",
|
||||||
|
"Synacktiv Publications": "https://www.synacktiv.com/en/publications?rss",
|
||||||
|
"Starlabs Blog": "https://starlabs.sg/blog/index.xml",
|
||||||
|
"NCC Group Research": "https://research.nccgroup.com/feed/",
|
||||||
|
"Check Point Research": "https://research.checkpoint.com/feed/",
|
||||||
|
"RET2 Systems Blog": "https://blog.ret2.io/feed.xml",
|
||||||
|
"secret club": "https://secret.club/feed.xml",
|
||||||
|
"j00ru vx tech": "https://j00ru.vexillium.org/feed/",
|
||||||
|
"Connor McGarr": "https://connormcgarr.github.io/feed.xml",
|
||||||
|
"phoenhex team": "https://phoenhex.re/feed.xml",
|
||||||
|
"Google Project Zero": "https://googleprojectzero.blogspot.com/feeds/posts/default",
|
||||||
|
"SpecterOps Blog": "https://specterops.io/blog/",
|
||||||
|
"Hack The Box - Red Teaming": "https://www.hackthebox.com/rss/blog/red-teaming",
|
||||||
|
"Code White": "https://code-white.com/rss/",
|
||||||
|
"TrustedSec Blog": "https://www.trustedsec.com/feed/",
|
||||||
|
"Outflank Blog": "https://outflank.nl/blog/feed/",
|
||||||
|
"Pentest Partners Blog": "https://www.pentestpartners.com/feed/",
|
||||||
|
"Black Hills InfoSec Blog": "https://www.blackhillsinfosec.com/blog/feed/",
|
||||||
|
"xpnsec Blog": "https://blog.xpnsec.com/rss/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"threat_intel": {
|
||||||
|
"SANS Internet Storm Center": "https://isc.sans.edu/rssfeed.xml",
|
||||||
|
"Vulnerability Lab": "https://www.vulnerability-lab.com/rss/rss.php",
|
||||||
|
"Proofpoint Threat Insight": "https://www.proofpoint.com/us/rss.xml",
|
||||||
|
"Unit42 Palo Alto Networks": "https://unit42.paloaltonetworks.com/feed/",
|
||||||
|
"SOCRadar Threat Intel": "https://socradar.io/feed/",
|
||||||
|
"Google Cloud Threat Intelligence": "https://cloud.google.com/blog/topics/threat-intelligence.rss",
|
||||||
|
"Microsoft Security": "https://www.microsoft.com/en-us/security/blog/feed/",
|
||||||
|
"Microsoft Security Response Center": "https://msrc.microsoft.com/blog/rss/",
|
||||||
|
"Mandiant": "https://www.mandiant.com/resources/blog/rss.xml",
|
||||||
|
"Cisco Talos": "http://feeds.feedburner.com/feedburner/Talos",
|
||||||
|
"CrowdStrike": "https://www.crowdstrike.com/blog/feed/"
|
||||||
|
}
|
||||||
|
}
|
||||||
+555
@@ -0,0 +1,555 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
RSS Feed Manager for Threat Intelligence Bot
|
||||||
|
Handles feed parsing, article extraction, and message formatting
|
||||||
|
"""
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
import hashlib
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from html import escape
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
level=logging.INFO
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RSSFeedManager:
|
||||||
|
def __init__(self, feeds_file: str = "feeds.json", seen_file: str = "seen_articles.db", feed_type: str = "general"):
|
||||||
|
self.feeds_file = feeds_file
|
||||||
|
self.seen_file = seen_file
|
||||||
|
self.feed_type = feed_type # 'daily', 'blogs', or 'general'
|
||||||
|
self.feeds = {}
|
||||||
|
self.seen_articles = {} # Changed to dict to store timestamps
|
||||||
|
self.session = None
|
||||||
|
self.first_run = True
|
||||||
|
self.load_feeds()
|
||||||
|
self.load_seen_articles()
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Async context manager entry"""
|
||||||
|
self.session = aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=30),
|
||||||
|
headers={'User-Agent': 'ThreatIntel-Bot/1.0'}
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Async context manager exit"""
|
||||||
|
if self.session:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
def load_feeds(self):
|
||||||
|
"""Load RSS feeds from JSON file"""
|
||||||
|
if not self.feeds_file:
|
||||||
|
self.feeds = {}
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(self.feeds_file, 'r') as f:
|
||||||
|
self.feeds = json.load(f)
|
||||||
|
logger.info(f"Loaded {sum(len(category) for category in self.feeds.values())} feeds from {len(self.feeds)} categories")
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"Feeds file {self.feeds_file} not found")
|
||||||
|
self.feeds = {}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Error parsing feeds file: {e}")
|
||||||
|
self.feeds = {}
|
||||||
|
|
||||||
|
def load_seen_articles(self):
|
||||||
|
"""Load seen article keys from SQLite, migrating from legacy JSON when present."""
|
||||||
|
self._ensure_seen_db()
|
||||||
|
self._migrate_legacy_seen_json()
|
||||||
|
self._load_seen_from_sqlite()
|
||||||
|
|
||||||
|
def _ensure_seen_db(self):
|
||||||
|
"""Create seen-article database and indexes if missing."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS seen_articles (
|
||||||
|
article_key TEXT PRIMARY KEY,
|
||||||
|
seen_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_seen_articles_seen_at ON seen_articles (seen_at)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initializing seen-article database: {e}")
|
||||||
|
|
||||||
|
def _load_seen_from_sqlite(self):
|
||||||
|
"""Load seen keys from SQLite into memory."""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
|
cursor = conn.execute("SELECT article_key, seen_at FROM seen_articles")
|
||||||
|
self.seen_articles = {row[0]: row[1] for row in cursor.fetchall()}
|
||||||
|
self.first_run = len(self.seen_articles) == 0
|
||||||
|
logger.info(f"Loaded {len(self.seen_articles)} seen articles (first_run: {self.first_run})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading seen articles from database: {e}")
|
||||||
|
self.seen_articles = {}
|
||||||
|
self.first_run = True
|
||||||
|
|
||||||
|
def _migrate_legacy_seen_json(self):
|
||||||
|
"""One-time migration from legacy seen_articles.json into SQLite."""
|
||||||
|
if Path(self.seen_file).suffix.lower() == ".json":
|
||||||
|
return
|
||||||
|
|
||||||
|
json_path = Path(self.seen_file).with_suffix(".json")
|
||||||
|
if not json_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
|
existing_count = conn.execute("SELECT COUNT(1) FROM seen_articles").fetchone()[0]
|
||||||
|
if existing_count > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(json_path, "r") as f:
|
||||||
|
seen_data = json.load(f)
|
||||||
|
|
||||||
|
current_time = datetime.now(timezone.utc).isoformat()
|
||||||
|
raw_seen = seen_data.get("seen", {})
|
||||||
|
if isinstance(raw_seen, list):
|
||||||
|
migrated = {str(k): current_time for k in raw_seen}
|
||||||
|
elif isinstance(raw_seen, dict):
|
||||||
|
migrated = {str(k): str(v) for k, v in raw_seen.items()}
|
||||||
|
else:
|
||||||
|
migrated = {}
|
||||||
|
|
||||||
|
if migrated:
|
||||||
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR REPLACE INTO seen_articles (article_key, seen_at) VALUES (?, ?)",
|
||||||
|
[(k, v) for k, v in migrated.items()],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Migrated {len(migrated)} seen articles from {json_path.name} to SQLite")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Error parsing legacy seen_articles.json during migration")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error migrating legacy seen articles: {e}")
|
||||||
|
|
||||||
|
def save_seen_articles(self):
|
||||||
|
"""Save seen article keys with retention in SQLite."""
|
||||||
|
try:
|
||||||
|
cutoff_time = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
cutoff_iso = cutoff_time.isoformat()
|
||||||
|
cleaned_articles = {}
|
||||||
|
with sqlite3.connect(self.seen_file) as conn:
|
||||||
|
conn.execute("DELETE FROM seen_articles WHERE seen_at < ?", (cutoff_iso,))
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR REPLACE INTO seen_articles (article_key, seen_at) VALUES (?, ?)",
|
||||||
|
[(k, v) for k, v in self.seen_articles.items()],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cursor = conn.execute("SELECT article_key, seen_at FROM seen_articles")
|
||||||
|
cleaned_articles = {row[0]: row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
self.seen_articles = cleaned_articles
|
||||||
|
logger.info(f"Saved {len(self.seen_articles)} seen articles (cleaned old entries)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving seen articles: {e}")
|
||||||
|
|
||||||
|
def generate_article_hash(self, article: Dict) -> str:
|
||||||
|
"""Generate unique hash for article based on URL only for better duplicate detection"""
|
||||||
|
# Use only the URL for hash to catch duplicates across different RSS sources
|
||||||
|
# Different sources may have same article with different titles/dates
|
||||||
|
url = article.get('link', '')
|
||||||
|
return hashlib.md5(url.encode()).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def canonicalize_url(url: str) -> str:
|
||||||
|
"""Normalize URLs so tracking variants map to one dedup key."""
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
tracking_params = {"fbclid", "gclid", "mc_cid", "mc_eid", "igshid", "mkt_tok", "yclid"}
|
||||||
|
parts = urlsplit(url.strip())
|
||||||
|
|
||||||
|
filtered_query = []
|
||||||
|
for key, value in parse_qsl(parts.query, keep_blank_values=True):
|
||||||
|
lowered = key.lower()
|
||||||
|
if lowered.startswith("utm_") or lowered in tracking_params:
|
||||||
|
continue
|
||||||
|
filtered_query.append((key, value))
|
||||||
|
|
||||||
|
cleaned_query = urlencode(filtered_query, doseq=True)
|
||||||
|
cleaned_path = parts.path.rstrip("/") if parts.path not in ("", "/") else parts.path
|
||||||
|
|
||||||
|
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), cleaned_path, cleaned_query, ""))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_text(value: str) -> str:
|
||||||
|
"""Normalize text for stable content fingerprinting."""
|
||||||
|
return " ".join((value or "").lower().split())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_article_fingerprints(cls, article: Dict) -> Dict[str, str]:
|
||||||
|
"""Build URL/content fingerprints for dedup checks."""
|
||||||
|
raw_url = article.get('url') or article.get('link') or ""
|
||||||
|
canonical_url = cls.canonicalize_url(raw_url)
|
||||||
|
|
||||||
|
title = cls.normalize_text(article.get('title', ''))
|
||||||
|
description = cls.normalize_text(article.get('description', ''))
|
||||||
|
content_blob = f"{title}|{description}"
|
||||||
|
|
||||||
|
url_fp = hashlib.sha256(canonical_url.encode("utf-8")).hexdigest() if canonical_url else ""
|
||||||
|
content_fp = hashlib.sha256(content_blob.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'url_key': f"url:{url_fp}" if url_fp else "",
|
||||||
|
'content_key': f"content:{content_fp}",
|
||||||
|
'canonical_url': canonical_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_article_from_today(self, published_dt: datetime) -> bool:
|
||||||
|
"""Allow only items published on today's UTC date."""
|
||||||
|
now_utc = datetime.now(timezone.utc).date()
|
||||||
|
if published_dt.tzinfo is None:
|
||||||
|
published_dt = published_dt.replace(tzinfo=timezone.utc)
|
||||||
|
return published_dt.astimezone(timezone.utc).date() == now_utc
|
||||||
|
|
||||||
|
def is_duplicate_article(self, article: Dict) -> bool:
|
||||||
|
"""Check URL/content fingerprint duplicates against sent-history."""
|
||||||
|
fingerprints = self.get_article_fingerprints(article)
|
||||||
|
url_key = fingerprints['url_key']
|
||||||
|
content_key = fingerprints['content_key']
|
||||||
|
return (url_key and url_key in self.seen_articles) or (content_key in self.seen_articles)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mark_article_as_sent(cls, article: Dict, seen_file: str = "seen_articles.db"):
|
||||||
|
"""Persist fingerprints only after successful delivery."""
|
||||||
|
manager = cls(feeds_file="", seen_file=seen_file, feed_type=article.get('feed_type', 'general'))
|
||||||
|
fingerprints = cls.get_article_fingerprints(article)
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
if fingerprints['url_key']:
|
||||||
|
manager.seen_articles[fingerprints['url_key']] = now_iso
|
||||||
|
manager.seen_articles[fingerprints['content_key']] = now_iso
|
||||||
|
manager.save_seen_articles()
|
||||||
|
|
||||||
|
def clean_html(self, text: str) -> str:
|
||||||
|
"""Clean HTML tags and decode entities"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Remove HTML tags
|
||||||
|
soup = BeautifulSoup(text, 'html.parser')
|
||||||
|
cleaned = soup.get_text()
|
||||||
|
# Clean up whitespace
|
||||||
|
cleaned = ' '.join(cleaned.split())
|
||||||
|
return cleaned[:500] + "..." if len(cleaned) > 500 else cleaned
|
||||||
|
|
||||||
|
def extract_thumbnail(self, article: Dict, content: str = "") -> Optional[str]:
|
||||||
|
"""Extract thumbnail URL from article"""
|
||||||
|
# Try media content first
|
||||||
|
media_content = getattr(article, 'media_content', [])
|
||||||
|
if media_content and isinstance(media_content, list):
|
||||||
|
for media in media_content:
|
||||||
|
if media.get('type', '').startswith('image/'):
|
||||||
|
return media.get('url')
|
||||||
|
|
||||||
|
# Try enclosures
|
||||||
|
enclosures = getattr(article, 'enclosures', [])
|
||||||
|
for enclosure in enclosures:
|
||||||
|
if enclosure.get('type', '').startswith('image/'):
|
||||||
|
return enclosure.get('href')
|
||||||
|
|
||||||
|
# Try media thumbnail
|
||||||
|
if hasattr(article, 'media_thumbnail'):
|
||||||
|
thumbnails = getattr(article, 'media_thumbnail', [])
|
||||||
|
if thumbnails:
|
||||||
|
return thumbnails[0].get('url')
|
||||||
|
|
||||||
|
# Try parsing content for images
|
||||||
|
if content:
|
||||||
|
soup = BeautifulSoup(content, 'html.parser')
|
||||||
|
img_tags = soup.find_all('img')
|
||||||
|
for img in img_tags:
|
||||||
|
src = img.get('src')
|
||||||
|
if src and any(ext in src.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
|
||||||
|
return src
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch_feed(self, url: str, name: str, initial_run: bool = False) -> List[Dict]:
|
||||||
|
"""Fetch and parse single RSS feed"""
|
||||||
|
try:
|
||||||
|
async with self.session.get(url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
content = await response.text()
|
||||||
|
feed = feedparser.parse(content)
|
||||||
|
|
||||||
|
if feed.bozo and feed.bozo_exception:
|
||||||
|
logger.warning(f"Feed parsing warning for {name}: {feed.bozo_exception}")
|
||||||
|
|
||||||
|
articles = []
|
||||||
|
for entry in feed.entries[:10]: # Limit to 10 most recent
|
||||||
|
# Parse publication date. Skip undated items to prevent stale replays.
|
||||||
|
published = getattr(entry, 'published_parsed', None) or getattr(entry, 'updated_parsed', None)
|
||||||
|
if not published:
|
||||||
|
continue
|
||||||
|
|
||||||
|
published_dt = datetime(*published[:6], tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Strict real-time gate: today's UTC articles only.
|
||||||
|
if not self.is_article_from_today(published_dt):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get content/description
|
||||||
|
content = ""
|
||||||
|
if hasattr(entry, 'content') and entry.content:
|
||||||
|
content = entry.content[0].value if isinstance(entry.content, list) else entry.content
|
||||||
|
elif hasattr(entry, 'summary'):
|
||||||
|
content = entry.summary
|
||||||
|
elif hasattr(entry, 'description'):
|
||||||
|
content = entry.description
|
||||||
|
|
||||||
|
article = {
|
||||||
|
'hash': self.generate_article_hash(entry),
|
||||||
|
'title': self.clean_html(getattr(entry, 'title', 'No Title')),
|
||||||
|
'description': self.clean_html(content),
|
||||||
|
'url': self.canonicalize_url(getattr(entry, 'link', '')),
|
||||||
|
'published': published_dt.isoformat(),
|
||||||
|
'published_human': published_dt.strftime('%Y-%m-%d %H:%M UTC'),
|
||||||
|
'source': name,
|
||||||
|
'category': '', # Will be set by caller
|
||||||
|
'thumbnail': self.extract_thumbnail(entry, content),
|
||||||
|
'feed_type': self.feed_type # Track which feed type this came from
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip if article has already been sent recently.
|
||||||
|
if self.is_duplicate_article(article):
|
||||||
|
continue
|
||||||
|
|
||||||
|
articles.append(article)
|
||||||
|
|
||||||
|
if initial_run and articles:
|
||||||
|
logger.info(f"Initial run - marked {len(articles)} articles from {name} as seen")
|
||||||
|
elif articles:
|
||||||
|
logger.info(f"Fetched {len(articles)} new articles from {name}")
|
||||||
|
|
||||||
|
return articles
|
||||||
|
else:
|
||||||
|
logger.error(f"HTTP {response.status} for feed {name}: {url}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"Timeout fetching feed {name}: {url}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching feed {name}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def fetch_all_feeds(self, initial_run: bool = False) -> List[Dict]:
|
||||||
|
"""Fetch all RSS feeds concurrently"""
|
||||||
|
if not self.session:
|
||||||
|
raise RuntimeError("RSS Manager not initialized. Use 'async with' context manager.")
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for category_name, feeds in self.feeds.items():
|
||||||
|
for feed_name, feed_url in feeds.items():
|
||||||
|
task = self.fetch_feed(feed_url, feed_name, initial_run)
|
||||||
|
tasks.append((task, category_name))
|
||||||
|
|
||||||
|
all_articles = []
|
||||||
|
results = await asyncio.gather(*[task for task, _ in tasks], return_exceptions=True)
|
||||||
|
|
||||||
|
for (_, category), result in zip(tasks, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.error(f"Exception in category {category}: {result}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for article in result:
|
||||||
|
article['category'] = category
|
||||||
|
all_articles.append(article)
|
||||||
|
|
||||||
|
# Sort by published date (newest first)
|
||||||
|
all_articles.sort(key=lambda x: x['published'], reverse=True)
|
||||||
|
|
||||||
|
if initial_run and all_articles:
|
||||||
|
# On first run, mark today's currently-visible items as sent to avoid startup flood.
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
for article in all_articles:
|
||||||
|
fingerprints = self.get_article_fingerprints(article)
|
||||||
|
if fingerprints['url_key']:
|
||||||
|
self.seen_articles[fingerprints['url_key']] = now_iso
|
||||||
|
self.seen_articles[fingerprints['content_key']] = now_iso
|
||||||
|
self.save_seen_articles()
|
||||||
|
logger.info(f"Initial run: Marked {len(all_articles)} existing articles as seen (no alerts sent)")
|
||||||
|
return [] # Don't send alerts on first run
|
||||||
|
|
||||||
|
logger.info(f"Total new articles: {len(all_articles)}")
|
||||||
|
return all_articles
|
||||||
|
|
||||||
|
async def check_feed_status(self) -> Dict[str, Dict]:
|
||||||
|
"""Check status of all feeds"""
|
||||||
|
if not self.session:
|
||||||
|
raise RuntimeError("RSS Manager not initialized. Use 'async with' context manager.")
|
||||||
|
|
||||||
|
status = {}
|
||||||
|
|
||||||
|
for category_name, feeds in self.feeds.items():
|
||||||
|
status[category_name] = {}
|
||||||
|
for feed_name, feed_url in feeds.items():
|
||||||
|
try:
|
||||||
|
async with self.session.get(feed_url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
content = await response.text()
|
||||||
|
feed = feedparser.parse(content)
|
||||||
|
entry_count = len(feed.entries)
|
||||||
|
last_updated = "Unknown"
|
||||||
|
|
||||||
|
if feed.entries:
|
||||||
|
latest = feed.entries[0]
|
||||||
|
if hasattr(latest, 'published_parsed') and latest.published_parsed:
|
||||||
|
dt = datetime(*latest.published_parsed[:6], tzinfo=timezone.utc)
|
||||||
|
last_updated = dt.strftime('%Y-%m-%d %H:%M UTC')
|
||||||
|
|
||||||
|
status[category_name][feed_name] = {
|
||||||
|
'status': '✅ Online',
|
||||||
|
'entries': entry_count,
|
||||||
|
'last_updated': last_updated,
|
||||||
|
'http_status': response.status
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
status[category_name][feed_name] = {
|
||||||
|
'status': '❌ HTTP Error',
|
||||||
|
'entries': 0,
|
||||||
|
'last_updated': 'Error',
|
||||||
|
'http_status': response.status
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
status[category_name][feed_name] = {
|
||||||
|
'status': '❌ Error',
|
||||||
|
'entries': 0,
|
||||||
|
'last_updated': 'Error',
|
||||||
|
'error': str(e)[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_telegram_message(article: Dict) -> Tuple[str, Optional[str]]:
|
||||||
|
"""Format article for Telegram message"""
|
||||||
|
# Category emoji mapping
|
||||||
|
category_emojis = {
|
||||||
|
'news': '📰',
|
||||||
|
'malware': '🦠',
|
||||||
|
'threat_intel': '🛰️',
|
||||||
|
'osint': '🕵️',
|
||||||
|
'research': '🔬'
|
||||||
|
}
|
||||||
|
|
||||||
|
emoji = category_emojis.get(article.get('category', ''), '📰')
|
||||||
|
category_display = article.get('category', '').replace('_', ' ').title()
|
||||||
|
|
||||||
|
title = escape(article.get('title', 'No Title'))
|
||||||
|
description = escape(article.get('description', ''))
|
||||||
|
source = escape(article.get('source', 'Unknown Source'))
|
||||||
|
published_human = escape(article.get('published_human', 'Unknown'))
|
||||||
|
category_safe = escape(category_display)
|
||||||
|
url = escape(article.get('url', ''), quote=True)
|
||||||
|
severity = article.get('severity', 'unknown').lower()
|
||||||
|
quality_score = article.get('quality_score')
|
||||||
|
cves = article.get('cves', [])[:3]
|
||||||
|
classifications = article.get('classifications', [])[:3]
|
||||||
|
|
||||||
|
severity_emoji = {
|
||||||
|
'critical': '🚨',
|
||||||
|
'high': '🔴',
|
||||||
|
'medium': '🟠',
|
||||||
|
'low': '🟡',
|
||||||
|
}.get(severity, '⚪')
|
||||||
|
severity_safe = escape(severity.upper())
|
||||||
|
|
||||||
|
message = f"{emoji} <b>{title}</b>\n\n"
|
||||||
|
|
||||||
|
if description:
|
||||||
|
message += f"📋 {description}\n\n"
|
||||||
|
|
||||||
|
message += f"🏷️ <b>Category:</b> {category_safe}\n"
|
||||||
|
message += f"📡 <b>Source:</b> {source}\n"
|
||||||
|
message += f"{severity_emoji} <b>Severity:</b> {severity_safe}\n"
|
||||||
|
if quality_score is not None:
|
||||||
|
message += f"⭐ <b>Quality:</b> {int(quality_score)}/100\n"
|
||||||
|
if classifications:
|
||||||
|
class_text = ", ".join(escape(c) for c in classifications)
|
||||||
|
message += f"🧠 <b>Type:</b> {class_text}\n"
|
||||||
|
if cves:
|
||||||
|
cve_text = ", ".join(escape(cve) for cve in cves)
|
||||||
|
message += f"🆔 <b>CVEs:</b> {cve_text}\n"
|
||||||
|
why_this_matters = RSSFeedManager.build_why_this_matters(article)
|
||||||
|
if why_this_matters:
|
||||||
|
message += f"🎯 <b>Why This Matters:</b> {escape(why_this_matters)}\n"
|
||||||
|
message += f"⏰ <b>Published:</b> {published_human}\n"
|
||||||
|
message += f"🔗 <b><a href=\"{url}\">Read Full Article</a></b>"
|
||||||
|
|
||||||
|
return message, article.get('thumbnail')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_why_this_matters(article: Dict) -> str:
|
||||||
|
"""Generate a concise operator-grade impact sentence."""
|
||||||
|
reasons = []
|
||||||
|
severity = str(article.get("severity", "")).lower()
|
||||||
|
cves = article.get("cves", []) or []
|
||||||
|
actors = article.get("threat_actors", []) or []
|
||||||
|
classes = set(article.get("classifications", []) or [])
|
||||||
|
quality = int(article.get("quality_score", 0) or 0)
|
||||||
|
|
||||||
|
if severity in {"critical", "high"}:
|
||||||
|
reasons.append(f"{severity.upper()} severity")
|
||||||
|
if cves:
|
||||||
|
reasons.append(f"references {len(cves)} CVE(s)")
|
||||||
|
if actors:
|
||||||
|
reasons.append("mentions known threat actors")
|
||||||
|
if "poc" in classes:
|
||||||
|
reasons.append("exploit/PoC indicators present")
|
||||||
|
if "advisory" in classes:
|
||||||
|
reasons.append("actionable advisory/patch context")
|
||||||
|
if not reasons and quality >= 70:
|
||||||
|
reasons.append("high-signal technical reporting")
|
||||||
|
|
||||||
|
return "; ".join(reasons[:3])
|
||||||
|
|
||||||
|
# Example usage for testing
|
||||||
|
async def main():
|
||||||
|
async with RSSFeedManager() as rss_manager:
|
||||||
|
# Test fetching feeds
|
||||||
|
articles = await rss_manager.fetch_all_feeds()
|
||||||
|
|
||||||
|
for article in articles[:3]: # Show first 3 articles
|
||||||
|
message, thumbnail = rss_manager.format_telegram_message(article)
|
||||||
|
print("=" * 50)
|
||||||
|
print(message)
|
||||||
|
if thumbnail:
|
||||||
|
print(f"Thumbnail: {thumbnail}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test status check
|
||||||
|
print("Feed Status Check:")
|
||||||
|
status = await rss_manager.check_feed_status()
|
||||||
|
for category, feeds in status.items():
|
||||||
|
print(f"\n{category}:")
|
||||||
|
for feed_name, feed_status in feeds.items():
|
||||||
|
print(f" {feed_name}: {feed_status['status']} ({feed_status['entries']} entries)")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Threat Intelligence RSS Feed Telegram Bot
|
||||||
|
Main bot script handling commands, subscriptions, and real-time alerts
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict
|
||||||
|
from pathlib import Path
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
ApplicationBuilder,
|
||||||
|
CommandHandler,
|
||||||
|
ContextTypes,
|
||||||
|
MessageHandler,
|
||||||
|
filters
|
||||||
|
)
|
||||||
|
from telegram.constants import ParseMode
|
||||||
|
from telegram.error import TelegramError
|
||||||
|
|
||||||
|
from rss_manager import RSSFeedManager
|
||||||
|
from content_classifier import ContentClassifier
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
level=logging.INFO
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CATEGORY_CONFIG = {
|
||||||
|
"news": {"label": "News", "emoji": "📰", "feeds_file": "feeds/news_feeds.json"},
|
||||||
|
"malware": {"label": "Malware", "emoji": "🦠", "feeds_file": "feeds/malware_feeds.json"},
|
||||||
|
"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"},
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThreatIntelBot:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
subscribers_file: str = "subscribers.json",
|
||||||
|
min_quality_score: int = 0,
|
||||||
|
allowed_severities: str = "critical,high,medium,low",
|
||||||
|
):
|
||||||
|
self.token = token
|
||||||
|
self.subscribers_file = subscribers_file
|
||||||
|
self.subscribers: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self.classifier = ContentClassifier()
|
||||||
|
self.min_quality_score = max(0, min(min_quality_score, 100))
|
||||||
|
self.allowed_severities = {
|
||||||
|
value.strip().lower()
|
||||||
|
for value in allowed_severities.split(",")
|
||||||
|
if value.strip()
|
||||||
|
} or {"critical", "high", "medium", "low"}
|
||||||
|
self.application = None
|
||||||
|
self.monitoring_task = None
|
||||||
|
self.load_subscribers()
|
||||||
|
|
||||||
|
def load_subscribers(self):
|
||||||
|
"""Load subscriber list from file with feed type support"""
|
||||||
|
try:
|
||||||
|
with open(self.subscribers_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
# Migrate old format to new format
|
||||||
|
if 'subscribers' in data and isinstance(data['subscribers'], list):
|
||||||
|
# Old format: convert list to dict and preserve "all feeds" intent.
|
||||||
|
self.subscribers = {
|
||||||
|
str(sub_id): {'topic_id': None, 'feed_types': list(CATEGORY_CONFIG.keys())}
|
||||||
|
for sub_id in data['subscribers']
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# New format: dict with topic info and feed types.
|
||||||
|
raw_subscribers = data.get('subscribers', {})
|
||||||
|
normalized: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for chat_key, info in raw_subscribers.items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
topic_id = info.get('topic_id')
|
||||||
|
raw_feed_types = info.get('feed_types', [])
|
||||||
|
if isinstance(raw_feed_types, list):
|
||||||
|
mapped = []
|
||||||
|
for feed in raw_feed_types:
|
||||||
|
if feed in CATEGORY_CONFIG:
|
||||||
|
mapped.append(feed)
|
||||||
|
elif feed == "daily":
|
||||||
|
mapped.extend(["news", "threat_intel", "osint", "malware"])
|
||||||
|
elif feed == "blogs":
|
||||||
|
mapped.extend(["research", "threat_intel", "malware"])
|
||||||
|
feed_types = sorted(set(mapped))
|
||||||
|
else:
|
||||||
|
feed_types = []
|
||||||
|
|
||||||
|
normalized[str(chat_key)] = {
|
||||||
|
'topic_id': topic_id,
|
||||||
|
'feed_types': feed_types
|
||||||
|
}
|
||||||
|
|
||||||
|
self.subscribers = normalized
|
||||||
|
logger.info(f"Loaded {len(self.subscribers)} subscribers")
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.info("No subscribers file found, starting with empty list")
|
||||||
|
self.subscribers = {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Error parsing subscribers file")
|
||||||
|
self.subscribers = {}
|
||||||
|
|
||||||
|
def save_subscribers(self):
|
||||||
|
"""Save subscriber list to file"""
|
||||||
|
try:
|
||||||
|
with open(self.subscribers_file, 'w') as f:
|
||||||
|
json.dump({'subscribers': self.subscribers}, f, indent=2)
|
||||||
|
logger.info(f"Saved {len(self.subscribers)} subscribers")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving subscribers: {e}")
|
||||||
|
|
||||||
|
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle /start command"""
|
||||||
|
user_name = update.effective_user.first_name or "User"
|
||||||
|
|
||||||
|
command_lines = []
|
||||||
|
for key, cfg in CATEGORY_CONFIG.items():
|
||||||
|
command_lines.append(
|
||||||
|
f"{cfg['emoji']} `{('/on_' + key)}` / `{('/off_' + key)}` - {cfg['label']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
welcome_message = (
|
||||||
|
f"🔴 **Welcome to Threat Intelligence Bot, {user_name}!** 🔴\n\n"
|
||||||
|
"This bot monitors cybersecurity RSS feeds and delivers alerts to Telegram topics.\n\n"
|
||||||
|
"**📂 Category Commands:**\n"
|
||||||
|
+ "\n".join(f"• {line}" for line in command_lines)
|
||||||
|
+ "\n\n**🛠️ General Commands:**\n"
|
||||||
|
"• `/stats` - Check feed status and statistics\n"
|
||||||
|
"• `/help` - Show this help message\n\n"
|
||||||
|
"**💡 Tip for Telegram Groups with Topics:**\n"
|
||||||
|
"Run each `/on_<category>` command inside the topic where that category should post."
|
||||||
|
)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
welcome_message,
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle /help command"""
|
||||||
|
await self.start_command(update, context)
|
||||||
|
|
||||||
|
async def category_on_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE, category: str):
|
||||||
|
"""Handle /on_<category> command."""
|
||||||
|
if category not in CATEGORY_CONFIG:
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
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)
|
||||||
|
is_forum_topic = message_thread_id is not None
|
||||||
|
cfg = CATEGORY_CONFIG[category]
|
||||||
|
|
||||||
|
if subscriber_key not in self.subscribers:
|
||||||
|
self.subscribers[subscriber_key] = {'topic_id': message_thread_id, 'feed_types': []}
|
||||||
|
|
||||||
|
if category not in self.subscribers[subscriber_key]['feed_types']:
|
||||||
|
self.subscribers[subscriber_key]['feed_types'].append(category)
|
||||||
|
self.save_subscribers()
|
||||||
|
chat_type = "forum topic" if is_forum_topic else ("group" if update.effective_chat.type in ['group', 'supergroup'] else "private chat")
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"{cfg['emoji']} **{cfg['label']} Activated!**\n\n"
|
||||||
|
f"You will now receive **{cfg['label'].lower()}** alerts in this {chat_type}.\n"
|
||||||
|
f"Type `/off_{category}` to unsubscribe.",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if is_forum_topic else None
|
||||||
|
)
|
||||||
|
logger.info(f"Subscriber {subscriber_key} enabled {category.upper()} - Topic ID: {message_thread_id}")
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"✅ {cfg['label']} is already enabled for this {('topic' if is_forum_topic else 'chat')}.",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if is_forum_topic else None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def category_off_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE, category: str):
|
||||||
|
"""Handle /off_<category> command."""
|
||||||
|
if category not in CATEGORY_CONFIG:
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
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)
|
||||||
|
is_forum_topic = message_thread_id is not None
|
||||||
|
cfg = CATEGORY_CONFIG[category]
|
||||||
|
|
||||||
|
if subscriber_key in self.subscribers and category in self.subscribers[subscriber_key].get('feed_types', []):
|
||||||
|
self.subscribers[subscriber_key]['feed_types'].remove(category)
|
||||||
|
if not self.subscribers[subscriber_key]['feed_types']:
|
||||||
|
del self.subscribers[subscriber_key]
|
||||||
|
self.save_subscribers()
|
||||||
|
|
||||||
|
chat_type = "topic" if is_forum_topic else "chat"
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"✋ **{cfg['label']} disabled.**\n\n"
|
||||||
|
f"This {chat_type} will no longer receive {cfg['label'].lower()} alerts.\n"
|
||||||
|
f"Type `/on_{category}` to re-subscribe.",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if is_forum_topic else None
|
||||||
|
)
|
||||||
|
logger.info(f"Subscriber {subscriber_key} disabled {category.upper()} - Topic ID: {message_thread_id}")
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"❌ This chat is not subscribed to {cfg['label'].lower()} alerts.",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if is_forum_topic else None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stats_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle /stats command - show feed status for all categories."""
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
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)
|
||||||
|
|
||||||
|
if subscriber_key not in self.subscribers:
|
||||||
|
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.",
|
||||||
|
message_thread_id=message_thread_id if message_thread_id else None
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🔍 **Checking feed status...** Please wait...",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if message_thread_id else None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_feeds = 0
|
||||||
|
online_feeds = 0
|
||||||
|
|
||||||
|
# Send header message
|
||||||
|
await update.message.reply_text(
|
||||||
|
"📊 **Feed Status Report**\n",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if message_thread_id else None
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
for category, cfg in CATEGORY_CONFIG.items():
|
||||||
|
async with RSSFeedManager(feeds_file=cfg["feeds_file"], feed_type=category) as manager:
|
||||||
|
status_data = await manager.check_feed_status()
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"{cfg['emoji']} **{cfg['label'].upper()} FEEDS:**\n",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if message_thread_id else None
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
for feed_category, feeds in status_data.items():
|
||||||
|
category_display = feed_category.replace('_', ' ').title()
|
||||||
|
category_message = f"**{category_display}:**\n"
|
||||||
|
|
||||||
|
for feed_name, feed_info in feeds.items():
|
||||||
|
total_feeds += 1
|
||||||
|
status_icon = feed_info['status']
|
||||||
|
entries = feed_info['entries']
|
||||||
|
last_updated = feed_info['last_updated']
|
||||||
|
|
||||||
|
if '✅' in status_icon:
|
||||||
|
online_feeds += 1
|
||||||
|
|
||||||
|
feed_line = f"• {feed_name}: {status_icon}\n"
|
||||||
|
if entries > 0:
|
||||||
|
feed_line += f" └ {entries} articles, last: {last_updated}\n"
|
||||||
|
if 'error' in feed_info:
|
||||||
|
feed_line += f" └ Error: {feed_info['error'][:50]}\n"
|
||||||
|
category_message += feed_line
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
category_message,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if message_thread_id else None
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
# Send summary as final message
|
||||||
|
summary = f"📈 **Summary:**\n"
|
||||||
|
summary += f"• Total Feeds: {total_feeds}\n"
|
||||||
|
summary += f"• Online: {online_feeds}\n"
|
||||||
|
summary += f"• Offline: {total_feeds - online_feeds}\n"
|
||||||
|
summary += f"• Subscribers: {len(self.subscribers)}\n"
|
||||||
|
summary += f"• Last Check: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
summary,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if message_thread_id else None
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in stats command: {e}")
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ Error checking feed status. Please try again later.",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
message_thread_id=message_thread_id if message_thread_id else None
|
||||||
|
)
|
||||||
|
async def handle_non_subscribers(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle messages from non-subscribers - completely silent for groups"""
|
||||||
|
# Bot remains silent for all non-command messages
|
||||||
|
# Only responds to slash commands, ignores everything else
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_alert(self, article: Dict, context: ContextTypes.DEFAULT_TYPE = None) -> bool:
|
||||||
|
"""Send threat intel alert to subscribers based on feed type."""
|
||||||
|
if not self.subscribers:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
message, thumbnail = RSSFeedManager.format_telegram_message(article)
|
||||||
|
|
||||||
|
failed_sends = []
|
||||||
|
article_feed_type = article.get('feed_type', 'general')
|
||||||
|
successful_sends = 0
|
||||||
|
|
||||||
|
for subscriber_key, sub_info in self.subscribers.copy().items():
|
||||||
|
# Check if subscriber wants this feed type
|
||||||
|
feed_types = sub_info.get('feed_types', [])
|
||||||
|
if article_feed_type not in feed_types:
|
||||||
|
continue # Skip if subscriber doesn't want this feed type
|
||||||
|
|
||||||
|
# Parse chat_id from subscriber_key (format: "chatid_topicid" or "chatid")
|
||||||
|
chat_id_part = subscriber_key.split('_')[0]
|
||||||
|
chat_id = int(chat_id_part)
|
||||||
|
topic_id = sub_info.get('topic_id') if isinstance(sub_info, dict) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if thumbnail:
|
||||||
|
# Try to send with photo
|
||||||
|
try:
|
||||||
|
await self.application.bot.send_photo(
|
||||||
|
chat_id=chat_id,
|
||||||
|
photo=thumbnail,
|
||||||
|
caption=message,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
message_thread_id=topic_id
|
||||||
|
)
|
||||||
|
successful_sends += 1
|
||||||
|
except TelegramError:
|
||||||
|
# Fallback to text if photo fails
|
||||||
|
await self.application.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=message,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
message_thread_id=topic_id
|
||||||
|
)
|
||||||
|
successful_sends += 1
|
||||||
|
else:
|
||||||
|
await self.application.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=message,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
message_thread_id=topic_id
|
||||||
|
)
|
||||||
|
successful_sends += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send to {chat_id}: {e}")
|
||||||
|
if "bot was blocked" in str(e).lower() or "chat not found" in str(e).lower():
|
||||||
|
failed_sends.append(subscriber_key)
|
||||||
|
|
||||||
|
# Remove blocked/invalid subscribers
|
||||||
|
for failed_key in failed_sends:
|
||||||
|
self.subscribers.pop(failed_key, None)
|
||||||
|
logger.info(f"Removed blocked/invalid subscriber: {failed_key}")
|
||||||
|
|
||||||
|
if failed_sends:
|
||||||
|
self.save_subscribers()
|
||||||
|
|
||||||
|
if successful_sends > 0:
|
||||||
|
# Mark as sent only after at least one Telegram delivery succeeded.
|
||||||
|
RSSFeedManager.mark_article_as_sent(article, seen_file="seen_articles.db")
|
||||||
|
logger.info(f"Alert sent to {successful_sends} subscribers: {article['title'][:50]}...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info(f"No eligible subscribers for article: {article['title'][:50]}...")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending alert: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def monitor_feeds(self):
|
||||||
|
"""Background task to monitor RSS feeds by category."""
|
||||||
|
logger.info("Starting RSS feed monitoring for category feeds...")
|
||||||
|
|
||||||
|
# First run - just mark existing articles as seen, don't send alerts
|
||||||
|
try:
|
||||||
|
for category, cfg in CATEGORY_CONFIG.items():
|
||||||
|
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...")
|
||||||
|
existing_articles = await manager.fetch_all_feeds(initial_run=True)
|
||||||
|
logger.info(
|
||||||
|
f"Completed initial {category.upper()} scan. "
|
||||||
|
f"Found {len(existing_articles)} existing articles (no alerts sent)"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in initial feed scan: {e}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
all_articles = []
|
||||||
|
|
||||||
|
for category, cfg in CATEGORY_CONFIG.items():
|
||||||
|
async with RSSFeedManager(feeds_file=cfg["feeds_file"], feed_type=category) as manager:
|
||||||
|
category_articles = await manager.fetch_all_feeds(initial_run=False)
|
||||||
|
all_articles.extend(category_articles)
|
||||||
|
if category_articles:
|
||||||
|
logger.info(f"Found {len(category_articles)} new {category.upper()} articles")
|
||||||
|
|
||||||
|
# Deduplicate within this polling batch (cross-feed/source duplicates).
|
||||||
|
unique_articles = []
|
||||||
|
batch_seen_url = set()
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Send alerts for new articles
|
||||||
|
sent_count = 0
|
||||||
|
filtered_articles = []
|
||||||
|
for article in unique_articles:
|
||||||
|
classified = self.classifier.classify_article(article)
|
||||||
|
if classified.get("quality_score", 0) < self.min_quality_score:
|
||||||
|
continue
|
||||||
|
if classified.get("severity", "low") not in self.allowed_severities:
|
||||||
|
continue
|
||||||
|
filtered_articles.append(classified)
|
||||||
|
|
||||||
|
for article in filtered_articles:
|
||||||
|
sent = await self.send_alert(article)
|
||||||
|
if sent:
|
||||||
|
sent_count += 1
|
||||||
|
# Small delay to avoid rate limiting
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if unique_articles:
|
||||||
|
logger.info(
|
||||||
|
f"Processed {len(unique_articles)} unique new articles; "
|
||||||
|
f"after filters {len(filtered_articles)}; delivered {sent_count}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("No new articles found")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in feed monitoring: {e}")
|
||||||
|
|
||||||
|
# Wait 5 minutes before next check
|
||||||
|
await asyncio.sleep(300)
|
||||||
|
|
||||||
|
def build_application(self):
|
||||||
|
"""Build the Telegram application"""
|
||||||
|
# Build application
|
||||||
|
self.application = ApplicationBuilder().token(self.token).build()
|
||||||
|
|
||||||
|
# Add command handlers
|
||||||
|
self.application.add_handler(CommandHandler("start", self.start_command))
|
||||||
|
self.application.add_handler(CommandHandler("help", self.help_command))
|
||||||
|
for category in CATEGORY_CONFIG:
|
||||||
|
self.application.add_handler(
|
||||||
|
CommandHandler(f"on_{category}", partial(self.category_on_command, category=category))
|
||||||
|
)
|
||||||
|
self.application.add_handler(
|
||||||
|
CommandHandler(f"off_{category}", partial(self.category_off_command, category=category))
|
||||||
|
)
|
||||||
|
self.application.add_handler(CommandHandler("stats", self.stats_command))
|
||||||
|
|
||||||
|
# Handle all other messages (for non-subscribers)
|
||||||
|
self.application.add_handler(
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_non_subscribers)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Telegram bot application built successfully")
|
||||||
|
|
||||||
|
async def start_monitoring(self):
|
||||||
|
"""Start the background monitoring task"""
|
||||||
|
if not self.monitoring_task:
|
||||||
|
self.monitoring_task = asyncio.create_task(self.monitor_feeds())
|
||||||
|
logger.info("RSS monitoring task started")
|
||||||
|
|
||||||
|
async def stop_monitoring(self):
|
||||||
|
"""Stop the background monitoring task"""
|
||||||
|
if self.monitoring_task:
|
||||||
|
self.monitoring_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.monitoring_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self.monitoring_task = None
|
||||||
|
logger.info("RSS monitoring task stopped")
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Run the bot"""
|
||||||
|
self.build_application()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Initialize and start the application
|
||||||
|
await self.application.initialize()
|
||||||
|
await self.application.start()
|
||||||
|
|
||||||
|
# Start background monitoring
|
||||||
|
await self.start_monitoring()
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
await self.application.updater.start_polling()
|
||||||
|
|
||||||
|
logger.info("🚀 Threat Intel Bot is running!")
|
||||||
|
logger.info(f"📊 {len(self.subscribers)} subscribers loaded")
|
||||||
|
|
||||||
|
# Keep running until interrupted
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Received interrupt, shutting down...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error running bot: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
await self.stop_monitoring()
|
||||||
|
await self.application.stop()
|
||||||
|
await self.application.shutdown()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function"""
|
||||||
|
# Load .env file if it exists
|
||||||
|
env_file = Path('.env')
|
||||||
|
if env_file.exists():
|
||||||
|
with open(env_file, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#') and '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
os.environ[key.strip()] = value.strip().strip('"\'')
|
||||||
|
|
||||||
|
# Get bot token from environment variable
|
||||||
|
bot_token = os.getenv('BOT_TOKEN')
|
||||||
|
if not bot_token:
|
||||||
|
logger.error("BOT_TOKEN not found!")
|
||||||
|
logger.info("Either:")
|
||||||
|
logger.info("1. Set environment variable: export BOT_TOKEN='your_token'")
|
||||||
|
logger.info("2. Create .env file with: BOT_TOKEN=your_token")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create and run bot
|
||||||
|
try:
|
||||||
|
min_quality_score = int(os.getenv("MIN_QUALITY_SCORE", "0"))
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid MIN_QUALITY_SCORE; defaulting to 0")
|
||||||
|
min_quality_score = 0
|
||||||
|
allowed_severities = os.getenv("ALLOWED_SEVERITIES", "critical,high,medium,low")
|
||||||
|
bot = ThreatIntelBot(
|
||||||
|
bot_token,
|
||||||
|
min_quality_score=min_quality_score,
|
||||||
|
allowed_severities=allowed_severities,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(bot.run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Bot stopped by user")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bot crashed: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user