{"slug": "how-to-build-a-minimal-siem-with-python-sqlite-and-telegram-alerts", "title": "How to Build a Minimal SIEM with Python, SQLite and Telegram Alerts", "summary": "A developer built a minimal Security Information and Event Management (SIEM) system using Python, SQLite, and Telegram alerts, requiring less than 400 lines of code and no external infrastructure. The system tails log files, stores events in SQLite with WAL mode for concurrent access, and runs detection rules to alert on threats like SSH brute-force attempts.", "body_md": "Most teams can't afford Splunk, and Elastic SIEM takes real time to tune. Yet you still need to know when someone is brute-forcing your SSH, when a suspicious process spawns at 3 AM, or when a web server starts returning a flood of 500s. A minimal SIEM built with Python, SQLite, and Telegram can cover these cases with less than 400 lines of code and zero additional infrastructure.\n\nThis article walks through building a working prototype you can deploy and extend immediately.\n\nA Security Information and Event Management system does three things:\n\nThat's it. Everything else — dashboards, threat intelligence enrichment, ML anomaly detection — is additive. The goal here is a foundation that runs on a single VM, a Raspberry Pi, or a $6/month VPS without any external service dependencies.\n\nSQLite is the right tool for a minimal SIEM. It's file-based, needs no server process, and handles tens of millions of rows comfortably. We use two tables: `events`\n\nfor raw log lines and `detections`\n\nfor fired rules.\n\n``` python\nimport sqlite3\nfrom pathlib import Path\n\nDB_PATH = Path(\"/var/db/siem.db\")\n\ndef init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:\n    conn = sqlite3.connect(db_path)\n    conn.execute(\"PRAGMA journal_mode=WAL\")  # concurrent reads won't block writes\n    conn.execute(\"PRAGMA synchronous=NORMAL\")\n\n    conn.executescript(\"\"\"\n        CREATE TABLE IF NOT EXISTS events (\n            id          INTEGER PRIMARY KEY AUTOINCREMENT,\n            ts          INTEGER NOT NULL,   -- unix epoch ms\n            source      TEXT NOT NULL,      -- e.g. sshd, nginx, auditd\n            host        TEXT NOT NULL,\n            raw         TEXT NOT NULL,      -- original log line\n            severity    TEXT DEFAULT 'info'\n        );\n\n        CREATE TABLE IF NOT EXISTS detections (\n            id          INTEGER PRIMARY KEY AUTOINCREMENT,\n            ts          INTEGER NOT NULL,\n            rule_id     TEXT NOT NULL,\n            event_ids   TEXT NOT NULL,      -- JSON array of matching event IDs\n            summary     TEXT NOT NULL,\n            notified    INTEGER DEFAULT 0\n        );\n\n        CREATE INDEX IF NOT EXISTS idx_events_ts     ON events(ts);\n        CREATE INDEX IF NOT EXISTS idx_events_source ON events(source);\n    \"\"\")\n    conn.commit()\n    return conn\n```\n\nOne critical detail: `WAL`\n\nmode allows a reader and a writer to work simultaneously. Without it, your log ingestion locks the database and the detection loop stalls.\n\nWe tail `/var/log/auth.log`\n\nusing `subprocess`\n\n. The same pattern works for nginx, auditd, or any file-based log source.\n\n``` python\nimport subprocess\nimport time\nimport re\nfrom dataclasses import dataclass\n\n@dataclass\nclass LogEvent:\n    ts: int\n    source: str\n    host: str\n    raw: str\n    severity: str = \"info\"\n\n# Matches: \"Failed password for root from 1.2.3.4\"\nSSH_FAILED = re.compile(\n    r\"Failed password for (?:\\w+ )?(?P<user>\\S+) from (?P<ip>[\\d\\.]+)\"\n)\n\ndef tail_auth_log(path: str = \"/var/log/auth.log\"):\n    proc = subprocess.Popen(\n        [\"tail\", \"-F\", \"-n\", \"0\", path],\n        stdout=subprocess.PIPE,\n        stderr=subprocess.DEVNULL,\n        text=True,\n    )\n    try:\n        for line in proc.stdout:\n            line = line.rstrip()\n            ts = int(time.time() * 1000)\n            severity = \"warning\" if SSH_FAILED.search(line) else \"info\"\n            yield LogEvent(ts=ts, source=\"sshd\", host=\"localhost\",\n                           raw=line, severity=severity)\n    finally:\n        proc.terminate()\n\ndef ingest(conn, event: LogEvent):\n    conn.execute(\n        \"INSERT INTO events (ts, source, host, raw, severity) VALUES (?,?,?,?,?)\",\n        (event.ts, event.source, event.host, event.raw, event.severity),\n    )\n    conn.commit()\n```\n\nFor multi-host environments, ship logs via syslog-ng to a central host and swap the `tail`\n\nfor a UDP listener. The schema doesn't change.\n\nThe detection loop queries recent events and matches them against rule functions. Here's a brute-force SSH rule that fires when the same IP produces more than 5 failed logins within 60 seconds:\n\n``` python\nimport json\n\ndef detect_ssh_brute_force(conn, window_sec: int = 60, threshold: int = 5):\n    now_ms = int(time.time() * 1000)\n    since_ms = now_ms - window_sec * 1000\n\n    rows = conn.execute(\n        \"SELECT id, raw FROM events \"\n        \"WHERE source = 'sshd' AND severity = 'warning' AND ts >= ?\",\n        (since_ms,),\n    ).fetchall()\n\n    ip_events: dict[str, list[int]] = {}\n    for row_id, raw in rows:\n        m = SSH_FAILED.search(raw)\n        if m:\n            ip = m.group(\"ip\")\n            ip_events.setdefault(ip, []).append(row_id)\n\n    results = []\n    for ip, event_ids in ip_events.items():\n        if len(event_ids) >= threshold:\n            summary = (\n                f\"SSH brute force from {ip}: \"\n                f\"{len(event_ids)} attempts in {window_sec}s\"\n            )\n            results.append((\"ssh_brute_force\", json.dumps(event_ids), summary))\n    return results\n\ndef run_detections(conn):\n    for rule_fn in [detect_ssh_brute_force]:\n        for rule_id, event_ids, summary in rule_fn(conn):\n            # Deduplicate: skip if same rule+payload fired in the last 10 minutes\n            existing = conn.execute(\n                \"SELECT 1 FROM detections \"\n                \"WHERE rule_id=? AND event_ids=? AND ts >= ?\",\n                (rule_id, event_ids, int(time.time() * 1000) - 600_000),\n            ).fetchone()\n            if not existing:\n                conn.execute(\n                    \"INSERT INTO detections (ts, rule_id, event_ids, summary) \"\n                    \"VALUES (?,?,?,?)\",\n                    (int(time.time() * 1000), rule_id, event_ids, summary),\n                )\n                conn.commit()\n```\n\nThe deduplication check is not optional. Without it, an active brute-force campaign fires a fresh alert every 30 seconds until the detection window moves past it.\n\nTelegram's Bot API is the lowest-friction notification channel available: no SMTP setup, no PagerDuty account, no webhook hosting required. Get a bot token from [@botfather](https://dev.to/botfather) and your personal chat ID from @userinfobot — both free.\n\n``` python\nimport urllib.request\n\nTELEGRAM_TOKEN = \"your-bot-token\"\nTELEGRAM_CHAT_ID = \"your-chat-id\"\n\ndef send_telegram(message: str):\n    url = f\"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage\"\n    payload = json.dumps({\n        \"chat_id\": TELEGRAM_CHAT_ID,\n        \"text\": message,\n        \"parse_mode\": \"Markdown\",\n    }).encode()\n    req = urllib.request.Request(\n        url, data=payload,\n        headers={\"Content-Type\": \"application/json\"},\n    )\n    with urllib.request.urlopen(req, timeout=10) as resp:\n        return json.loads(resp.read())\n\ndef flush_alerts(conn):\n    rows = conn.execute(\n        \"SELECT id, summary FROM detections WHERE notified = 0\"\n    ).fetchall()\n    for det_id, summary in rows:\n        try:\n            send_telegram(f\"🚨 *SIEM Alert*\\n{summary}\")\n            conn.execute(\n                \"UPDATE detections SET notified = 1 WHERE id = ?\", (det_id,)\n            )\n            conn.commit()\n        except Exception as e:\n            print(f\"[alert] failed to notify: {e}\")\n```\n\nUsing `urllib.request`\n\nkeeps the dependency list empty. For higher-volume deployments, swap this for `httpx`\n\nwith async support.\n\nRun ingestion in a background thread; detection and alerting run on the main thread every 30 seconds.\n\n``` python\nimport threading\n\ndef main():\n    conn = init_db()\n\n    def ingest_loop():\n        for event in tail_auth_log():\n            ingest(conn, event)\n\n    t = threading.Thread(target=ingest_loop, daemon=True)\n    t.start()\n\n    while True:\n        run_detections(conn)\n        flush_alerts(conn)\n        time.sleep(30)\n\nif __name__ == \"__main__\":\n    main()\n```\n\nDrop it under systemd for automatic restart on failure:\n\n```\n[Unit]\nDescription=Minimal SIEM\nAfter=network.target\n\n[Service]\nExecStart=/usr/bin/python3 /opt/siem/main.py\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\n```\n\nThis stack — Python + SQLite + Telegram — covers 80% of what small teams need from a SIEM at essentially zero infrastructure cost. The rule system is deliberately simple: add a new function that returns `(rule_id, event_ids, summary)`\n\ntuples and register it in `run_detections`\n\n. No framework, no YAML configuration, no agent to maintain.\n\nReasonable next steps: rules for failed `sudo`\n\nattempts, unexpected cron jobs, and outbound connections via `auditd`\n\n; a read-only UI with [Datasette](https://datasette.io); AbuseIPDB enrichment on flagged IPs. For a structured checklist of what to monitor and in what priority order, [our free security hardening checklists](https://ayinedjimi-consultants.fr/checklists) map detection goals to MITRE ATT&CK tactics.\n\n*I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.*", "url": "https://wpnews.pro/news/how-to-build-a-minimal-siem-with-python-sqlite-and-telegram-alerts", "canonical_source": "https://dev.to/ayinedjimi-consultants/how-to-build-a-minimal-siem-with-python-sqlite-and-telegram-alerts-29f5", "published_at": "2026-08-14 10:05:10+00:00", "updated_at": "2026-08-14 10:35:25.762727+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Python", "SQLite", "Telegram", "Splunk", "Elastic SIEM", "Raspberry Pi", "VPS"], "alternates": {"html": "https://wpnews.pro/news/how-to-build-a-minimal-siem-with-python-sqlite-and-telegram-alerts", "markdown": "https://wpnews.pro/news/how-to-build-a-minimal-siem-with-python-sqlite-and-telegram-alerts.md", "text": "https://wpnews.pro/news/how-to-build-a-minimal-siem-with-python-sqlite-and-telegram-alerts.txt", "jsonld": "https://wpnews.pro/news/how-to-build-a-minimal-siem-with-python-sqlite-and-telegram-alerts.jsonld"}}