DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

How I monitor CVEs daily with a 50-line Python script

Every morning I get a Telegram message with the CVEs that matter to my clients. Not the 150+ CVEs published daily by NIST — just the ones relevant to the technologies my consulting clients actually run: FortiGate, SonicWall, Palo Alto, pfSense, Windows Server, and a handful of others.

The script that does this is 50 lines of Python. It has been running in a cron job for over a year without meaningful issues. Here is how it works and what I learned building it.

The problem with raw CVE feeds

NIST publishes everything. That is their job. On a busy day the NVD feed has 200+ entries. If you subscribe to the RSS feed without filtering, you will either stop reading it or you will miss the one entry that matters.

The problem I needed to solve was not access to CVE data — it was relevance filtering at the point of ingestion.

NIST NVD API vs RSS

There are two main ways to consume CVE data programmatically:

The NVD REST API (https://services.nvd.nist.gov/rest/json/cves/2.0) is comprehensive and structured. It returns full CVE records with CVSS scores, CWE classifications, and CPE references. The catch: without an API key, you are rate-limited to 5 requests per 30 seconds. With a free API key (takes about a minute to register), that loosens significantly. But the API returns results in pages and requires handling pagination and retries.

The RSS feeds from various aggregators (NVD, CERT, CVE.org) are simpler for daily monitoring. They update every few hours, are easy to parse with feedparser, and require no authentication. The tradeoff is less structured data — you get the title, description, and published date, but not always clean CVSS scores.

For my use case — daily digest, keyword filtering, Telegram notification — RSS with keyword matching was the right call. I use the NVD API only when I need to look up a specific CVE's CVSS score.

The script

import feedparser
import requests
import hashlib
import json
from pathlib import Path
from datetime import datetime, timezone, timedelta

TELEGRAM_BOT_TOKEN = "YOUR_BOT_TOKEN"
TELEGRAM_CHAT_ID   = "YOUR_CHAT_ID"
SEEN_FILE          = Path("/tmp/.cve-seen.json")

KEYWORDS = [
    "fortinet", "fortigate", "fortios",
    "sonicwall", "palo alto", "panos",
    "pfSense", "opnsense",
    "windows server", "active directory", "kerberos",
    "vmware esxi", "vcenter",
    "cisco ios", "cisco asa",
]

CVE_FEEDS = [
    "https://feeds.feedburner.com/nvd-cve/rss",
    "https://www.cert.ssi.gouv.fr/alerte/feed/",  # ANSSI alerts
]

def load_seen() -> set:
    if SEEN_FILE.exists():
        return set(json.loads(SEEN_FILE.read_text()))
    return set()

def save_seen(seen: set) -> None:
    SEEN_FILE.write_text(json.dumps(list(seen)))

def is_recent(entry) -> bool:
    published = entry.get("published_parsed")
    if not published:
        return True  # include if date unknown
    pub_dt = datetime(*published[:6], tzinfo=timezone.utc)
    return datetime.now(timezone.utc) - pub_dt < timedelta(hours=26)

def matches_keywords(entry) -> bool:
    text = (entry.get("title", "") + " " + entry.get("summary", "")).lower()
    return any(kw.lower() in text for kw in KEYWORDS)

def entry_id(entry) -> str:
    return hashlib.md5(entry.get("link", entry.get("title", "")).encode()).hexdigest()

def send_telegram(message: str) -> None:
    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
    requests.post(url, json={
        "chat_id": TELEGRAM_CHAT_ID,
        "text": message,
        "parse_mode": "Markdown",
        "disable_web_page_preview": True,
    }, timeout=10)

def main():
    seen = load_seen()
    alerts = []

    for feed_url in CVE_FEEDS:
        feed = feedparser.parse(feed_url)
        for entry in feed.entries:
            eid = entry_id(entry)
            if eid in seen:
                continue
            if not is_recent(entry):
                continue
            if not matches_keywords(entry):
                continue
            seen.add(eid)
            title   = entry.get("title", "No title")
            link    = entry.get("link", "")
            summary = entry.get("summary", "")[:200]
            alerts.append(f"*{title}*\n{summary}...\n{link}")

    save_seen(seen)

    if alerts:
        header = f"CVE Alert — {datetime.now().strftime('%Y-%m-%d')} ({len(alerts)} new)\n\n"
        send_telegram(header + "\n\n---\n\n".join(alerts[:10]))  # cap at 10
    else:
        pass  # silence is fine — no news is good news

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The cron entry runs it every 6 hours:

0 */6 * * * /usr/bin/python3 /opt/scripts/cve-monitor.py
Enter fullscreen mode Exit fullscreen mode

Key lessons from a year of running this

Deduplication is mandatory. The same CVE gets picked up by multiple feeds at different times. The SEEN_FILE JSON persists CVE IDs across runs. Without it, you get the same alert 3 times in a day and start ignoring the channel — which defeats the purpose entirely.

CVSS score filtering is a trap. My first version filtered out anything below CVSS 7.0. I missed a CVSS 5.9 vulnerability in FortiOS that was actively exploited in the wild (EPSS score was 0.92, meaning 92% probability of exploitation in 30 days). CVSS measures theoretical severity; EPSS measures real-world exploitation probability. Neither alone is sufficient. I now include everything that matches keywords and let the human (me) decide the priority.

The NVD API rate limits will bite you. If you write a script that hammers the NVD API in a loop without delays, it will get throttled and you will see 503 errors. Add time.sleep(0.6) between requests when paginating. Or just use the RSS approach for daily monitoring and only hit the API for on-demand lookups.

Telegram is genuinely good for this. I tried email first. I stopped reading the emails within two weeks. Telegram messages in a dedicated channel are easy to scan, searchable, and link-friendly. The Bot API is straightforward and free.

Extending it

The natural next step is cross-referencing CVEs against your actual asset inventory. If you know your clients run FortiGate 7.2.x, you can filter not just by vendor but by version. NIST CPE data makes this possible but considerably more complex — I have not built that yet.

For the security checklists I publish at ayinedjimi-consultants.fr/checklists, CVE monitoring is one part of the maintenance cycle: when a new critical CVE drops for FortiGate or Windows Server, I review the relevant checklist to see if the mitigating control is already covered. Usually it is. Sometimes it is not and I update the checklist.

50 lines of Python, one cron job, one Telegram channel. The value comes not from the code but from the filtering — knowing which CVEs are noise and which ones should wake you up.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish security hardening checklists for FortiGate, Palo Alto, Active Directory, and more — free PDF and Excel.

Top comments (0)