Building a Domain Security Analyzer — The DNS Scanner Module
In the last post, we learned what Python packages are and why we need __init__.py. Now let's build the first real module in our scanner package — the DNS scanner.
Today's Goal
By the end of this post, you'll understand:
Why we configure a custom DNS resolver — And what happens if we don't
How each DNS function works — Line by line, no magic
Why we return dictionaries instead of mixed types — Consistency matters
How
scan_dns()ties everything together — One function to rule them all
The Complete Code
Here's the full dns_scanner.py file. We'll break down every piece:
"""DNS record scanning functionality."""
import dns.resolver
# Configure DNS resolver with public servers
_resolver = dns.resolver.Resolver(configure=False)
_resolver.nameservers = ['1.1.1.1', '8.8.8.8']
_resolver.timeout = 10
_resolver.lifetime = 10
def get_a_records(domain):
"""
Fetch A records (IPv4 addresses) for a domain.
Returns:
dict with 'success' bool and either 'records' list or 'error' string
"""
try:
answers = _resolver.resolve(domain, "A")
records = [rdata.address for rdata in answers]
return {
'success': True,
'records': records
}
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': f"Domain '{domain}' does not exist"
}
except dns.resolver.NoAnswer:
return {
'success': False,
'error': f"No A records found for '{domain}'"
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def get_mx_records(domain):
"""
Fetch MX (mail server) records for a domain.
Returns:
dict with 'success' bool and either 'records' list or 'error' string
"""
try:
answers = _resolver.resolve(domain, "MX")
records = []
for rdata in answers:
records.append({
'priority': rdata.preference,
'server': str(rdata.exchange).rstrip('.')
})
records.sort(key=lambda x: x['priority'])
return {
'success': True,
'records': records
}
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': f"Domain '{domain}' does not exist"
}
except dns.resolver.NoAnswer:
return {
'success': False,
'error': f"No MX records found for '{domain}'"
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def get_spf_record(domain):
"""
Fetch SPF record from TXT records.
Returns:
dict with 'success' bool and either 'record' string or 'error' string
"""
try:
answers = _resolver.resolve(domain, "TXT")
for rdata in answers:
txt_string = str(rdata).strip('"')
if txt_string.startswith('v=spf1'):
return {
'success': True,
'record': txt_string
}
return {
'success': False,
'error': 'No SPF record found'
}
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': f"Domain '{domain}' does not exist"
}
except dns.resolver.NoAnswer:
return {
'success': False,
'error': 'No TXT records found'
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def get_dmarc_record(domain):
"""
Fetch DMARC record from _dmarc subdomain.
Returns:
dict with 'success' bool and either 'record' string or 'error' string
"""
try:
dmarc_domain = f"_dmarc.{domain}"
answers = _resolver.resolve(dmarc_domain, "TXT")
for rdata in answers:
txt_string = str(rdata).strip('"')
if txt_string.startswith('v=DMARC1'):
return {
'success': True,
'record': txt_string
}
return {
'success': False,
'error': 'No DMARC record found'
}
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': 'No DMARC record found'
}
except dns.resolver.NoAnswer:
return {
'success': False,
'error': 'No DMARC record found'
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def scan_dns(domain):
"""
Run all DNS checks for a domain.
Returns:
dict with results from all DNS checks
"""
return {
'a_records': get_a_records(domain),
'mx_records': get_mx_records(domain),
'spf': get_spf_record(domain),
'dmarc': get_dmarc_record(domain)
}
Now let's understand every piece.
Part 1: The Module Docstring
"""DNS record scanning functionality."""
This describes what the entire file does. When someone imports this module and runs help(dns_scanner), they'll see this text. It's like a label on a folder — tells you what's inside without opening it.
Triple quotes allow multi-line strings, but even for single lines, we use triple quotes for docstrings. It's convention.
Part 2: The Import
import dns.resolver
This brings in the DNS lookup functionality from dnspython — the library we installed with pip install dnspython.
Why dns.resolver and not just dns?
The dnspython package has multiple modules inside it:
dnspython/
├── dns/
│ ├── resolver.py ← We need this one
│ ├── zone.py
│ ├── query.py
│ ├── rdatatype.py
│ └── ... many more
We specifically need the resolver module, which handles DNS queries. Think of it like importing a specific tool from a toolbox — you don't need the entire toolbox, just the screwdriver.
After this import, we can access things like:
dns.resolver.Resolver— A class to create resolver objectsdns.resolver.resolve()— A function to make DNS queriesdns.resolver.NXDOMAIN— An exception class for non-existent domains
Part 3: Configuring the DNS Resolver
This section is crucial. Let me explain why we need it and what each line does.
# Configure DNS resolver with public servers
_resolver = dns.resolver.Resolver(configure=False)
_resolver.nameservers = ['1.1.1.1', '8.8.8.8']
_resolver.timeout = 10
_resolver.lifetime = 10
Why Create a Custom Resolver?
By default, when you use dns.resolver.resolve(), it asks your computer's configured DNS server — usually your router, which asks your ISP's DNS server. This has problems:
Problem 1: Unreliability
Some ISP DNS servers are slow. Some have issues with certain record types. When we first tested our analyzer, SPF lookups would randomly time out. The ISP's DNS wasn't handling TXT record queries well.
Problem 2: Inconsistency
Different users have different ISPs. If your code works on your computer but fails on your friend's, debugging becomes a nightmare. Using public DNS servers ensures consistent behavior everywhere.
Line 1: Creating the Resolver Object
_resolver = dns.resolver.Resolver(configure=False)
Let's break this down piece by piece:
What is dns.resolver.Resolver?
It's a class provided by the dnspython library. A class is like a blueprint — Resolver is the blueprint for creating DNS resolver objects that can make queries.
What does Resolver(configure=False) do?
When you call Resolver(), you're creating an instance of that class — an actual resolver object you can use. The configure=False parameter is important:
configure=True(default): The resolver reads your system's DNS settings (from/etc/resolv.confon Linux, or Windows registry). It uses whatever DNS servers your computer is configured to use.configure=False: The resolver starts with a blank slate. No DNS servers configured. We'll add our own.
We want configure=False because we're going to specify exactly which DNS servers to use.
Why the underscore in _resolver?
The underscore prefix is a Python convention meaning "this is internal, don't use it from outside this module." It's like a "private" label.
Other parts of our code won't import _resolver directly — they'll use our functions (get_a_records, scan_dns, etc.) instead. The resolver is an implementation detail.
What's stored in _resolver?
After this line, _resolver holds a Resolver object. Think of it like this:
_resolver = {
nameservers: [], # Empty - we'll fill this
timeout: 2, # Default timeout
lifetime: 5, # Default lifetime
resolve: function(), # Method to make queries
... other methods and properties
}
It's not literally a dictionary, but conceptually it's an object with properties and methods we can use.
Line 2: Setting the DNS Servers
_resolver.nameservers = ['1.1.1.1', '8.8.8.8']
This tells our resolver which DNS servers to ask. We're setting a property on the object we just created.
| IP | Provider | Why we chose it |
1.1.1.1 | Cloudflare | Fast, privacy-focused, reliable |
8.8.8.8 | Widely trusted, excellent uptime |
The resolver tries 1.1.1.1 first. If that fails (timeout, error), it tries 8.8.8.8. Having two servers adds redundancy.
Lines 3-4: Setting Timeouts
_resolver.timeout = 10
_resolver.lifetime = 10
These control how long we wait for responses:
timeout = 10— Wait up to 10 seconds for each individual DNS server to respondlifetime = 10— Total time allowed for the entire query (including retries across multiple servers)
Why 10 seconds?
The default timeout is often 2-5 seconds, which isn't enough for:
Slow networks (coffee shop WiFi)
Complex queries (SPF records that reference other domains)
Busy DNS servers
10 seconds gives us breathing room without making users wait forever.
What's the difference between timeout and lifetime?
Imagine you're calling two friends to ask a question:
timeout= How long you wait before hanging up on each friendlifetime= Total time you're willing to spend before giving up entirely
If timeout=10 and lifetime=10, and the first server doesn't respond in 10 seconds, there's no time left to try the second server. In practice, you might set lifetime higher if you have many nameservers.
Part 4: Understanding _resolver.resolve()
Now we get to the core of DNS lookups. When we write:
answers = _resolver.resolve(domain, "A")
What's actually happening?
Breaking Down the Syntax
_resolver.resolve(domain, "A")
│ │ │ │
│ │ │ └── Second argument: record type
│ │ └── First argument: domain name
│ └── Method name (function that belongs to the object)
└── Our resolver object (created above)
_resolver — This is the variable we created earlier. It holds a Resolver object with our custom settings (Cloudflare/Google DNS, 10-second timeout).
.resolve — This is a method (a function that belongs to an object). The Resolver class comes with this method built-in. It's how we actually make DNS queries.
(domain, "A") — These are the arguments we pass to the method:
domain— The domain name to look up (like"github.com")"A"— The type of DNS record we want
What Happens Inside .resolve()
When you call _resolver.resolve("github.com", "A"), here's the sequence:
┌─────────────────────────────────────────────────────────────────┐
│ Your Computer │
│ │
│ _resolver.resolve("github.com", "A") │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 1. Build DNS query packet │ │
│ │ - Domain: github.com │ │
│ │ - Type: A (IPv4 address) │ │
│ │ - Class: IN (Internet) │ │
│ └─────────────────────────────────────────┘ │
│ │ │
└─────────┼───────────────────────────────────────────────────────┘
│
│ UDP packet to 1.1.1.1:53
▼
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare DNS (1.1.1.1) │
│ │
│ 2. Receives query: "What's the A record for github.com?" │
│ │
│ 3. Looks up answer (from cache or by querying other servers) │
│ │
│ 4. Sends response back │
│ │
└─────────────────────────────────────────────────────────────────┘
│
│ Response packet
▼
┌─────────────────────────────────────────────────────────────────┐
│ Your Computer │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 5. Parse response packet │ │
│ │ - Extract IP addresses │ │
│ │ - Create Answer objects │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ answers = [Answer(address='20.207.73.82'), ...] │
│ │
└─────────────────────────────────────────────────────────────────┘
What .resolve() Returns
The resolve() method returns an Answer object — an iterable collection of DNS records. It's not a simple list, but you can loop through it like one.
For github.com, the answer might contain:
answers = _resolver.resolve("github.com", "A")
# answers is like:
# [
# <dns.rdtypes.IN.A.A object>, # Contains address='20.207.73.82'
# <dns.rdtypes.IN.A.A object>, # Contains address='140.82.112.3'
# ]
Each item in answers is an rdata object (resource data). For A records, each rdata has an .address property containing the IP.
Why Not Just Return a List of IPs?
The dnspython library returns rich objects because different record types have different data:
| Record Type | What .resolve() returns | Properties available |
| A | A record objects | .address (IPv4) |
| AAAA | AAAA record objects | .address (IPv6) |
| MX | MX record objects | .preference, .exchange |
| TXT | TXT record objects | String content |
| CNAME | CNAME record objects | .target |
The library gives us objects so we can access whatever properties that record type has.
Part 5: The A Records Function — Complete Breakdown
Now let's trace through the entire function with a real example.
def get_a_records(domain):
"""
Fetch A records (IPv4 addresses) for a domain.
Returns:
dict with 'success' bool and either 'records' list or 'error' string
"""
try:
answers = _resolver.resolve(domain, "A")
records = [rdata.address for rdata in answers]
return {
'success': True,
'records': records
}
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': f"Domain '{domain}' does not exist"
}
except dns.resolver.NoAnswer:
return {
'success': False,
'error': f"No A records found for '{domain}'"
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
The Function Definition
def get_a_records(domain):
Defines a function named get_a_records that takes one parameter: domain. When we call get_a_records("github.com"), the value "github.com" gets assigned to the domain variable inside the function.
The Docstring
"""
Fetch A records (IPv4 addresses) for a domain.
Returns:
dict with 'success' bool and either 'records' list or 'error' string
"""
This documents what the function does and what it returns. The docstring tells us two critical things:
Purpose — Fetches A records (IPv4 addresses)
Return type — Always a dictionary with a
successkey
The Try Block
try:
answers = _resolver.resolve(domain, "A")
records = [rdata.address for rdata in answers]
return {
'success': True,
'records': records
}
Let's trace through with domain = "github.com":
Line 1: Make the DNS query
answers = _resolver.resolve(domain, "A")
This sends a DNS query to Cloudflare asking for A records. Let's expand what happens:
_resolver → Our configured resolver object
.resolve( → Call its resolve method
domain, → "github.com"
"A" → We want A (IPv4) records
) → Returns Answer object
answers = → Store the result
After this line, answers contains something like:
# Conceptually (not actual syntax):
answers = [
RdataA(address='20.207.73.82'),
]
Line 2: Extract the IP addresses
records = [rdata.address for rdata in answers]
This is a list comprehension — a compact way to build a list. Let's expand it:
# This list comprehension:
records = [rdata.address for rdata in answers]
# Is equivalent to this regular loop:
records = []
for rdata in answers:
ip_address = rdata.address
records.append(ip_address)
Breaking down the list comprehension:
[rdata.address for rdata in answers]
│ │ │
│ │ └── The collection to loop through
│ └── Loop variable (each item gets this name)
└── What to extract from each item
For each rdata object in answers, we grab its .address property (the IP address string) and collect them into a list.
If GitHub has one IP address, records becomes ['20.207.73.82']. If GitHub has three IPs, records becomes ['20.207.73.82', '140.82.112.3', '140.82.114.4'].
Line 3: Return success
return {
'success': True,
'records': records
}
We return a dictionary with:
'success': True— The query worked'records': records— The list of IP addresses
The Exception Handlers
Things can go wrong during DNS queries. Each except block handles a specific error:
NXDOMAIN — Domain doesn't exist
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': f"Domain '{domain}' does not exist"
}
NXDOMAIN stands for "Non-Existent Domain." This exception is raised when you query something like thisfakedomain12345.com — a domain that was never registered.
The dns.resolver.NXDOMAIN is an exception class defined in the dnspython library. When the DNS server responds "this domain doesn't exist," dnspython raises this specific exception.
NoAnswer — Domain exists but no A records
except dns.resolver.NoAnswer:
return {
'success': False,
'error': f"No A records found for '{domain}'"
}
NoAnswer means the domain exists, but has no A records. Some domains only have other record types. For example:
A domain used purely for email might have MX records but no A records
A subdomain configured only as a CNAME might not have direct A records
Catch-all for other errors
except Exception as e:
return {
'success': False,
'error': str(e)
}
This catches anything else — network timeouts, malformed responses, connection errors. The as e captures the exception object, and str(e) converts it to a readable error message.
Why Return Dictionaries Instead of Mixed Types?
In our old code, functions returned different types depending on success or failure:
# Old approach - inconsistent returns
def get_a_records(domain):
try:
answers = resolver.resolve(domain, "A")
return [rdata.address for rdata in answers] # Returns list
except:
return f"Error: something went wrong" # Returns string
The caller had to check the type:
result = get_a_records("github.com")
if isinstance(result, list): # Is it a list?
for ip in result:
print(ip)
else: # Must be an error string
print(result)
This is fragile. What if we forget to check? What if the error message accidentally looks like a list?
The new approach always returns a dictionary:
result = get_a_records("github.com")
if result['success']:
for ip in result['records']:
print(ip)
else:
print(result['error'])
Benefits:
Predictable structure — Always a dict with
successkeyExplicit success/failure — Boolean is unambiguous
Extensible — Easy to add more fields later (query time, server used, etc.)
Works with JSON — Dictionaries convert directly to JSON for APIs
Part 6: The MX Records Function
def get_mx_records(domain):
"""
Fetch MX (mail server) records for a domain.
Returns:
dict with 'success' bool and either 'records' list or 'error' string
"""
try:
answers = _resolver.resolve(domain, "MX")
records = []
for rdata in answers:
records.append({
'priority': rdata.preference,
'server': str(rdata.exchange).rstrip('.')
})
records.sort(key=lambda x: x['priority'])
return {
'success': True,
'records': records
}
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': f"Domain '{domain}' does not exist"
}
except dns.resolver.NoAnswer:
return {
'success': False,
'error': f"No MX records found for '{domain}'"
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
This is more complex than A records because MX records have two parts.
Understanding MX Record Structure
When you query MX records, each result has:
rdata.preference— A number indicating priority (lower = higher priority)rdata.exchange— The mail server hostname
GitHub's MX records might look like:
Priority 1: aspmx.l.google.com ← Try this first
Priority 5: alt1.aspmx.l.google.com ← First backup
Priority 5: alt2.aspmx.l.google.com ← Also first backup (same priority)
Priority 10: alt3.aspmx.l.google.com ← Last resort
The DNS Query
answers = _resolver.resolve(domain, "MX")
Same pattern as before, but we pass "MX" instead of "A". The resolver returns MX record objects.
Building the Records List
records = []
for rdata in answers:
records.append({
'priority': rdata.preference,
'server': str(rdata.exchange).rstrip('.')
})
Let's trace through:
records = []— Start with empty listfor rdata in answers— Loop through each MX recordFor each record, create a dictionary:
'priority': rdata.preference— The priority number (1, 5, 10, etc.)'server': str(rdata.exchange).rstrip('.')— The server hostname
Why str(rdata.exchange)?
The exchange property returns a Name object, not a plain string. We convert it with str() so we can work with it as text.
Why .rstrip('.')?
DNS internally stores hostnames with a trailing dot (aspmx.l.google.com.). This dot indicates "this is a fully qualified domain name" (not relative to anything). But users don't need to see this — it's a technical detail. .rstrip('.') removes the trailing dot.
Sorting by Priority
records.sort(key=lambda x: x['priority'])
This sorts the list so lower priority numbers come first.
What is lambda x: x['priority']?
It's an anonymous function — a mini-function without a name. Let's break it down:
lambda x: x['priority']
│ │ │
│ │ └── What to return (the priority value)
│ └── Input parameter
└── Keyword that creates a lambda
This is equivalent to:
def get_priority(x):
return x['priority']
records.sort(key=get_priority)
The key parameter tells .sort() what to compare. Instead of comparing entire dictionaries (which Python doesn't know how to do), it compares their priority values.
After sorting:
[
{'priority': 1, 'server': 'aspmx.l.google.com'},
{'priority': 5, 'server': 'alt1.aspmx.l.google.com'},
{'priority': 5, 'server': 'alt2.aspmx.l.google.com'},
{'priority': 10, 'server': 'alt3.aspmx.l.google.com'}
]
Why Not Use List Comprehension?
For A records, we used a list comprehension:
records = [rdata.address for rdata in answers]
We could write MX records similarly:
records = [{'priority': r.preference, 'server': str(r.exchange).rstrip('.')} for r in answers]
This works, but it's harder to read. When the logic gets complex (multiple operations per item), a regular for loop is clearer. Readability matters more than cleverness.
Part 7: The SPF Record Function
def get_spf_record(domain):
"""
Fetch SPF record from TXT records.
Returns:
dict with 'success' bool and either 'record' string or 'error' string
"""
try:
answers = _resolver.resolve(domain, "TXT")
for rdata in answers:
txt_string = str(rdata).strip('"')
if txt_string.startswith('v=spf1'):
return {
'success': True,
'record': txt_string
}
return {
'success': False,
'error': 'No SPF record found'
}
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': f"Domain '{domain}' does not exist"
}
except dns.resolver.NoAnswer:
return {
'success': False,
'error': 'No TXT records found'
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
Why Query TXT Records?
SPF records aren't a separate DNS record type — they're stored as TXT (text) records. A domain might have multiple TXT records for different purposes:
TXT: "v=spf1 include:_spf.google.com ~all" ← SPF record
TXT: "google-site-verification=abc123..." ← Google ownership verification
TXT: "facebook-domain-verification=xyz789..." ← Facebook verification
TXT: "MS=ms12345678" ← Microsoft verification
We need to find the one that starts with v=spf1.
The Search Logic
answers = _resolver.resolve(domain, "TXT")
Get all TXT records for the domain.
for rdata in answers:
txt_string = str(rdata).strip('"')
Loop through each TXT record. Convert to string and remove surrounding quotes — TXT records come wrapped in quotes like "v=spf1...".
if txt_string.startswith('v=spf1'):
return {
'success': True,
'record': txt_string
}
Check if this record starts with v=spf1. If yes, we found the SPF record — return it immediately. The return exits the function, so we don't check remaining records.
return {
'success': False,
'error': 'No SPF record found'
}
If the loop finishes without finding SPF, no record matched our criteria. Return failure.
Why 'record' Instead of 'records'?
A domain should only have one SPF record. Having multiple causes problems — mail servers might not know which one to use, or they might fail validation entirely.
So we return a single record string, not a records list. This naming distinction makes the return structure clear to callers.
Part 8: The DMARC Record Function
def get_dmarc_record(domain):
"""
Fetch DMARC record from _dmarc subdomain.
Returns:
dict with 'success' bool and either 'record' string or 'error' string
"""
try:
dmarc_domain = f"_dmarc.{domain}"
answers = _resolver.resolve(dmarc_domain, "TXT")
for rdata in answers:
txt_string = str(rdata).strip('"')
if txt_string.startswith('v=DMARC1'):
return {
'success': True,
'record': txt_string
}
return {
'success': False,
'error': 'No DMARC record found'
}
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': 'No DMARC record found'
}
except dns.resolver.NoAnswer:
return {
'success': False,
'error': 'No DMARC record found'
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
Why Query a Different Domain?
DMARC records live at a special subdomain. For github.com, the DMARC record is at _dmarc.github.com. This is defined in the DMARC specification (RFC 7489) — it keeps DMARC separate from other TXT records.
dmarc_domain = f"_dmarc.{domain}"
If domain is "github.com", this creates "_dmarc.github.com".
The f"..." is an f-string — Python's way of embedding variables in strings. The {domain} gets replaced with the actual value.
The Search Logic
for rdata in answers:
txt_string = str(rdata).strip('"')
if txt_string.startswith('v=DMARC1'):
return {
'success': True,
'record': txt_string
}
Same pattern as SPF — loop through TXT records, find the one starting with v=DMARC1.
Special Exception Handling
except dns.resolver.NXDOMAIN:
return {
'success': False,
'error': 'No DMARC record found'
}
Notice we return "No DMARC record found" instead of "Domain does not exist".
Why? If someone queries github.com, and _dmarc.github.com doesn't exist, the main domain still exists — it just doesn't have DMARC configured. Saying "Domain does not exist" would confuse users.
The user asked about github.com. From their perspective, GitHub exists; it just lacks a DMARC record.
Part 9: The scan_dns Function
def scan_dns(domain):
"""
Run all DNS checks for a domain.
Returns:
dict with results from all DNS checks
"""
return {
'a_records': get_a_records(domain),
'mx_records': get_mx_records(domain),
'spf': get_spf_record(domain),
'dmarc': get_dmarc_record(domain)
}
This is the public interface — the one function other parts of our code should call.
Why Have This Function?
Without it:
# The caller needs to know about all four functions
a = get_a_records(domain)
mx = get_mx_records(domain)
spf = get_spf_record(domain)
dmarc = get_dmarc_record(domain)
With it:
# One call, all results
dns_results = scan_dns(domain)
Benefits:
Simpler API — Callers don't need to know the internal structure
Easier changes — If we add
get_aaaa_records()later, we updatescan_dns()once. All callers automatically get IPv6 records without changing their code.Consistent results — The return structure is always the same
What the Return Looks Like
For github.com, scan_dns() returns:
{
'a_records': {
'success': True,
'records': ['20.207.73.82']
},
'mx_records': {
'success': True,
'records': [
{'priority': 1, 'server': 'aspmx.l.google.com'},
{'priority': 5, 'server': 'alt1.aspmx.l.google.com'},
{'priority': 5, 'server': 'alt2.aspmx.l.google.com'},
{'priority': 10, 'server': 'alt3.aspmx.l.google.com'},
{'priority': 10, 'server': 'alt4.aspmx.l.google.com'}
]
},
'spf': {
'success': True,
'record': 'v=spf1 ip4:192.30.252.0/22 include:_netblocks.google.com ...'
},
'dmarc': {
'success': True,
'record': 'v=DMARC1; p=reject; pct=100; rua=mailto:dmarc@github.com'
}
}
Nested dictionaries, completely predictable structure. Easy to work with in code, easy to convert to JSON for APIs.
How This Module Gets Used
In scanner/scanner.py (the main scanner we'll build later), we'll have:
from . import dns_scanner
class Scanner:
def __init__(self, domain):
self.domain = domain
def scan(self):
results = {
'dns': dns_scanner.scan_dns(self.domain),
'ssl': ssl_scanner.scan_ssl(self.domain),
'headers': headers_scanner.scan_headers(self.domain)
}
return results
The main scanner doesn't know about A records, MX records, or any DNS internals. It just calls scan_dns() and gets a complete dictionary.
This is separation of concerns in action. DNS logic stays in dns_scanner.py. The main scanner coordinates without getting into details. If we change how DNS scanning works internally, the main scanner doesn't need to change.
Summary: What We've Learned
Custom resolvers ensure reliability — Using public DNS servers (Cloudflare 1.1.1.1, Google 8.8.8.8) gives consistent results across all users, regardless of their ISP
_resolver.resolve()explained — It's a method call on our resolver object. The resolver sends queries to DNS servers and returns answer objects containing the recordsList comprehensions extract data —
[rdata.address for rdata in answers]loops through answers and collects IP addresses into a listConsistent return types matter — Always returning dictionaries with
successkey makes code predictable and easier to work withException handling needs context — Different errors get different user-friendly messages. NXDOMAIN for
_dmarc.github.comdoesn't mean "github.com doesn't exist"Single entry point simplifies usage —
scan_dns()combines all checks so callers don't need to know internal structure
What's Next
We have the DNS scanner done. In the next post, we'll build ssl_scanner.py — the module that checks SSL certificates. Same patterns (consistent returns, error handling), different domain knowledge (certificates, expiration dates, trust chains).
This is Part 6 of the Domain Security Analyzer series.
Find the code on GitHub.