DNS records change. Rarely on purpose. When an attacker hijacks a subdomain, poisons a registrar account, or a third-party provider silently updates their A records, you want to know within minutes — not during the next incident review.
This tutorial walks through building a Python tool that snapshots your DNS records, detects changes, and sends alerts via Slack. It is under 150 lines and deployable as a cron job.
Why DNS monitoring matters
DNS takeover is a well-documented attack vector. An attacker finds a dangling CNAME pointing to an expired third-party service, claims that service, and suddenly owns a subdomain of your domain. This has hit major companies and government agencies alike.
Even without malicious intent, accidental DNS changes cause outages. A changed MX record stops email delivery. A modified CNAME breaks a CDN integration. A new TXT record added by an overzealous developer can invalidate your DKIM setup silently.
You need a baseline and a diff engine. That is all this is.
Architecture overview
The tool does three things:
- Snapshot — query a set of record types for each monitored domain and store the result
- Diff — compare the current state against the last known snapshot
- Alert — send a notification when something changed
State is stored in a JSON file on disk. For a team setup, swap it for S3 or a small database — the diff logic stays identical.
Building the DNS snapshot fetcher
We will use dnspython for queries. Install with pip install dnspython requests.
import json
import dns.resolver
from pathlib import Path
from typing import Any
RECORD_TYPES = ["A", "AAAA", "MX", "NS", "TXT", "CNAME"]
def snapshot_domain(domain: str) -> dict[str, list[str]]:
resolver = dns.resolver.Resolver()
resolver.timeout = 5
resolver.lifetime = 10
records: dict[str, list[str]] = {}
for rtype in RECORD_TYPES:
try:
answers = resolver.resolve(domain, rtype)
records[rtype] = sorted(str(r) for r in answers)
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
records[rtype] = []
except dns.exception.DNSException:
# Transient error — skip this type rather than fail hard
pass
return records
def load_snapshot(path: Path) -> dict[str, Any]:
if path.exists():
return json.loads(path.read_text())
return {}
def save_snapshot(path: Path, data: dict[str, Any]) -> None:
path.write_text(json.dumps(data, indent=2))
The sorted() call matters: DNS responses can return records in any order. Without normalizing, a reordered A record response looks like a change when it is not.
Detecting changes and sending alerts
import requests
from datetime import datetime, timezone
def diff_snapshots(
previous: dict[str, list[str]],
current: dict[str, list[str]],
) -> dict[str, dict]:
changes: dict[str, dict] = {}
for rtype in set(previous) | set(current):
old = previous.get(rtype, [])
new = current.get(rtype, [])
if old != new:
changes[rtype] = {"before": old, "after": new}
return changes
def send_slack_alert(webhook_url: str, domain: str, changes: dict) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
lines = [f"*DNS change detected for {domain}* ({timestamp})"]
for rtype, diff in changes.items():
lines.append(f"\n*{rtype}*")
if diff["before"]:
lines.append(" - Removed: " + ", ".join(diff["before"]))
if diff["after"]:
lines.append(" + Added: " + ", ".join(diff["after"]))
response = requests.post(webhook_url, json={"text": "\n".join(lines)}, timeout=10)
response.raise_for_status()
The diff function handles new record types appearing (a CNAME added where none existed) and existing records disappearing entirely.
Wiring it together
import sys
DOMAINS = ["example.com", "api.example.com", "mail.example.com"]
SNAPSHOT_FILE = Path("/var/lib/dns-monitor/snapshots.json")
SLACK_WEBHOOK = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
def run() -> None:
previous_all = load_snapshot(SNAPSHOT_FILE)
current_all: dict[str, Any] = {}
alerted = False
for domain in DOMAINS:
current = snapshot_domain(domain)
current_all[domain] = current
previous = previous_all.get(domain, {})
if not previous:
print(f"[{domain}] First run — baseline saved.")
continue
changes = diff_snapshots(previous, current)
if changes:
print(f"[{domain}] Changes detected: {list(changes.keys())}")
send_slack_alert(SLACK_WEBHOOK, domain, changes)
alerted = True
else:
print(f"[{domain}] No changes.")
save_snapshot(SNAPSHOT_FILE, current_all)
sys.exit(1 if alerted else 0)
if __name__ == "__main__":
run()
The exit code is deliberate: exit 1 when changes are found lets a CI step fail or trigger a downstream notification chain.
Running it as a scheduled job
A crontab entry running every 5 minutes:
*/5 * * * * /usr/bin/python3 /opt/dns-monitor/monitor.py >> /var/log/dns-monitor.log 2>&1
Or use a systemd timer for cleaner output. It gives you journalctl -u dns-monitor for structured logs and avoids cron output-mailing problems:
# /etc/systemd/system/dns-monitor.timer
[Unit]
Description=DNS record change monitor
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
What to watch for in production
A few things that matter when running this seriously:
TTL propagation: DNS changes propagate gradually. Query against multiple resolvers (8.8.8.8, 1.1.1.1, your own authoritative server) to catch partial propagation states. Set resolver.nameservers = ["8.8.8.8", "1.1.1.1"] or run separate queries against each.
Alert fatigue: If you are monitoring 50 domains and a wildcard changes, you will get 50 alerts. Aggregate by change type across domains before sending.
Expected changes: Add a suppression list. If you rotate certificates every 90 days (new CAA or TXT records), pre-approve those changes in config so they do not page anyone at 3am.
Discrepancy detection: Run queries against different resolvers and flag when they disagree — that pattern catches attacks in progress where propagation has not completed yet.
DNS monitoring fits naturally alongside broader infrastructure hardening. If you are building out this kind of detection layer, the free security hardening checklists we publish cover DNS, TLS, and infrastructure configuration systematically — useful as a cross-check against what you have already deployed.
The takeaway
DNS monitoring is one of those things that takes a couple of hours to set up and then runs silently for years. Until one day it fires, and you catch an attack or accidental misconfiguration before it causes real damage.
The full implementation is around 100 lines of Python with two dependencies. Run it every 5 minutes. Store your baseline. Alert on diff.
There is no good reason not to have this running.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)