Building a Domain Security Analyzer — Restructuring for Scale
In the last post, we completed the core analysis features — DNS records, SSL certificates, and HTTP security headers. Our tool works. But there's a problem.
The Problem With Our Current Code
Open analyzer.py. It's one file with everything jammed together:
DNS lookup functions
SSL checking functions
HTTP header functions
Display logic (all those
printstatements)The CLI interface
This works for a script. But what happens when we want to:
Add a web interface — Flask can't use
printstatements. We need data returned, not printed.Store results in a database — We need structured data, not formatted strings.
Create an API — External tools want JSON, not console output.
Generate PDF reports — We need the raw data to format differently.
Right now, our functions mix two concerns: getting data and displaying data. They're tangled together. To add any new feature, we'd have to rewrite everything.
Today we fix that.
What is "Separation of Concerns"?
This is a fundamental principle in software design. Each piece of code should do one thing.
Bad: A function that fetches DNS records AND prints them to console.
Good: One function fetches DNS records. A separate function displays them.
Why does this matter?
Think of a restaurant kitchen. The chef cooks food. The waiter serves it. The chef doesn't walk out to tables — that would be chaos. If the restaurant adds delivery, they hire a driver. The chef still just cooks. The food (data) is the same; only the delivery method changes.
Our code should work the same way:
Scanner (the chef) → produces data
↓
ScanReport
↓
CLI Display (waiter) → shows data in terminal
Web Display (driver) → shows data in browser
API (takeout window) → returns JSON
PDF Export → formats as document
The scanner doesn't know or care how results will be displayed. It just produces data. Different "outputs" consume that data differently.
The New Project Structure
Here's what we're building:
domain-security-analyzer/
├── src/
│ ├── scanner/
│ │ ├── __init__.py
│ │ ├── dns_scanner.py # DNS checks
│ │ ├── ssl_scanner.py # SSL checks
│ │ ├── headers_scanner.py # HTTP header checks
│ │ └── scanner.py # Main scanner that uses all three
│ ├── models/
│ │ ├── __init__.py
│ │ └── report.py # ScanReport class
│ ├── cli.py # Command-line interface
│ └── analyzer.py # Entry point (what you run)
├── requirements.txt
└── README.md
Let's understand each piece:
| Folder/File | Purpose |
scanner/ | All code that fetches data. No printing, no formatting. Just returns dictionaries. |
models/ | Data structures. The ScanReport holds all results from a scan. |
cli.py | Takes a ScanReport and displays it in terminal. All print statements live here. |
analyzer.py | Entry point. Parses arguments, calls scanner, passes results to CLI. |
Create the Directory Structure
Navigate to your project's src folder and create the new directories:
cd src
mkdir scanner
mkdir models
Create the __init__.py files (these tell Python a folder is a package):
# On Windows:
type nul > scanner\__init__.py
type nul > models\__init__.py
# On Mac/Linux:
touch scanner/__init__.py
touch models/__init__.py
The New Files
Here's all the code for the restructured project. Create each file in the appropriate location.
scanner/init.py
"""Scanner package for domain security analysis."""
from .scanner import Scanner
__all__ = ['Scanner']
This file turns the scanner/ folder into a Python package and makes Scanner easy to import.
scanner/dns_scanner.py
"""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)
}
scanner/ssl_scanner.py
"""SSL certificate scanning functionality."""
import ssl
import socket
from datetime import datetime, timezone
def get_ssl_info(domain):
"""
Fetch SSL certificate information for a domain.
Returns:
dict with certificate details or error information
"""
try:
context = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=domain) as secure_sock:
cert = secure_sock.getpeercert()
# Parse dates
not_before = datetime.strptime(cert['notBefore'], '%b %d %H:%M:%S %Y %Z')
not_after = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
# Calculate days until expiry
now = datetime.now(timezone.utc).replace(tzinfo=None)
days_until_expiry = (not_after - now).days
# Extract issuer
issuer_dict = dict(x[0] for x in cert['issuer'])
issuer_name = issuer_dict.get('organizationName', 'Unknown')
# Extract subject
subject_dict = dict(x[0] for x in cert['subject'])
common_name = subject_dict.get('commonName', 'Unknown')
# Get SANs
san_list = []
for type_name, value in cert.get('subjectAltName', []):
if type_name == 'DNS':
san_list.append(value)
return {
'success': True,
'valid': True,
'common_name': common_name,
'issuer': issuer_name,
'not_before': not_before.strftime('%Y-%m-%d'),
'not_after': not_after.strftime('%Y-%m-%d'),
'days_until_expiry': days_until_expiry,
'subject_alt_names': san_list[:5],
'expired': days_until_expiry < 0,
'expiring_soon': 0 <= days_until_expiry <= 30
}
except ssl.SSLCertVerificationError as e:
return {
'success': True, # We connected, but cert failed validation
'valid': False,
'error': f"Certificate verification failed: {e.verify_message}"
}
except socket.timeout:
return {
'success': False,
'error': "Connection timed out"
}
except socket.gaierror:
return {
'success': False,
'error': f"Could not resolve domain '{domain}'"
}
except ConnectionRefusedError:
return {
'success': False,
'error': "Connection refused - HTTPS may not be enabled"
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def scan_ssl(domain):
"""
Run SSL checks for a domain.
Returns:
dict with SSL check results
"""
return get_ssl_info(domain)
scanner/headers_scanner.py
"""HTTP security headers scanning functionality."""
import requests
# Headers we check for and their descriptions
SECURITY_HEADERS = {
'Strict-Transport-Security': {
'description': 'Forces HTTPS connections',
'recommended': True
},
'Content-Security-Policy': {
'description': 'Controls allowed content sources',
'recommended': True
},
'X-Frame-Options': {
'description': 'Prevents clickjacking attacks',
'recommended': True
},
'X-Content-Type-Options': {
'description': 'Prevents MIME type sniffing',
'recommended': True
},
'Referrer-Policy': {
'description': 'Controls referrer information leakage',
'recommended': True
},
'Permissions-Policy': {
'description': 'Controls browser feature access',
'recommended': True
}
}
def get_security_headers(domain):
"""
Fetch and analyze HTTP security headers for a domain.
Returns:
dict with header analysis results
"""
try:
url = f"https://{domain}"
response = requests.get(url, timeout=10, allow_redirects=True)
headers_found = {}
for header_name, header_info in SECURITY_HEADERS.items():
header_value = response.headers.get(header_name)
headers_found[header_name] = {
'present': header_value is not None,
'value': header_value,
'description': header_info['description']
}
return {
'success': True,
'url': response.url,
'status_code': response.status_code,
'headers': headers_found
}
except requests.exceptions.SSLError as e:
return {
'success': False,
'error': f"SSL Error: {e}"
}
except requests.exceptions.ConnectionError:
return {
'success': False,
'error': f"Could not connect to {domain}"
}
except requests.exceptions.Timeout:
return {
'success': False,
'error': "Connection timed out"
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def scan_headers(domain):
"""
Run HTTP security header checks for a domain.
Returns:
dict with header check results
"""
return get_security_headers(domain)
scanner/scanner.py
"""Main scanner that coordinates all security checks."""
from datetime import datetime, timezone
from . import dns_scanner
from . import ssl_scanner
from . import headers_scanner
class Scanner:
"""
Scans a domain for security configuration.
Runs DNS, SSL, and HTTP header checks and returns
a comprehensive result dictionary.
"""
def __init__(self, domain):
"""
Initialize scanner with target domain.
Args:
domain: The domain to scan (e.g., 'github.com')
"""
self.domain = domain.lower().strip()
self.results = None
self.scan_time = None
def scan(self):
"""
Run all security checks on the domain.
Returns:
dict containing all scan results
"""
self.scan_time = datetime.now(timezone.utc)
self.results = {
'domain': self.domain,
'scan_time': self.scan_time.isoformat(),
'dns': dns_scanner.scan_dns(self.domain),
'ssl': ssl_scanner.scan_ssl(self.domain),
'headers': headers_scanner.scan_headers(self.domain)
}
return self.results
def get_results(self):
"""
Get the scan results.
Returns:
dict with results, or None if scan hasn't run
"""
return self.results
models/init.py
"""Data models package."""
from .report import ScanReport
__all__ = ['ScanReport']
models/report.py
"""Data models for scan reports."""
from datetime import datetime
class ScanReport:
"""
Holds the results of a domain security scan.
Provides easy access to different parts of the scan
and methods to check overall status.
"""
def __init__(self, scan_results):
"""
Initialize report from scanner results.
Args:
scan_results: dict returned by Scanner.scan()
"""
self.domain = scan_results['domain']
self.scan_time = scan_results['scan_time']
self.dns = scan_results['dns']
self.ssl = scan_results['ssl']
self.headers = scan_results['headers']
@property
def has_valid_ssl(self):
"""Check if SSL certificate is valid."""
return self.ssl.get('success') and self.ssl.get('valid', False)
@property
def ssl_days_remaining(self):
"""Get days until SSL certificate expires, or None if invalid."""
if self.has_valid_ssl:
return self.ssl.get('days_until_expiry')
return None
@property
def has_spf(self):
"""Check if SPF record exists."""
return self.dns['spf'].get('success', False)
@property
def has_dmarc(self):
"""Check if DMARC record exists."""
return self.dns['dmarc'].get('success', False)
@property
def missing_headers(self):
"""Get list of missing security headers."""
if not self.headers.get('success'):
return []
missing = []
for name, data in self.headers.get('headers', {}).items():
if not data['present']:
missing.append(name)
return missing
@property
def present_headers(self):
"""Get list of present security headers."""
if not self.headers.get('success'):
return []
present = []
for name, data in self.headers.get('headers', {}).items():
if data['present']:
present.append(name)
return present
def to_dict(self):
"""Convert report to dictionary (useful for JSON/database)."""
return {
'domain': self.domain,
'scan_time': self.scan_time,
'dns': self.dns,
'ssl': self.ssl,
'headers': self.headers
}
cli.py
"""Command-line interface display functions."""
def display_report(report):
"""
Display a scan report in the terminal.
Args:
report: ScanReport instance
"""
print(f"\n{'='*60}")
print(f" Security Report for {report.domain}")
print(f" Scanned at: {report.scan_time}")
print(f"{'='*60}\n")
display_dns(report)
display_ssl(report)
display_headers(report)
def display_dns(report):
"""Display DNS section of the report."""
print("DNS Records")
print("-" * 40)
# A Records
a_records = report.dns['a_records']
print("\n A Records (IPv4):")
if a_records['success']:
for ip in a_records['records']:
print(f" → {ip}")
else:
print(f" ✗ {a_records['error']}")
# MX Records
mx_records = report.dns['mx_records']
print("\n MX Records (Mail Servers):")
if mx_records['success']:
for mx in mx_records['records']:
print(f" → [{mx['priority']}] {mx['server']}")
else:
print(f" ✗ {mx_records['error']}")
# SPF
spf = report.dns['spf']
print("\n SPF Record:")
if spf['success']:
record = spf['record']
if len(record) > 60:
record = record[:60] + "..."
print(f" ✓ {record}")
else:
print(f" ✗ {spf['error']}")
# DMARC
dmarc = report.dns['dmarc']
print("\n DMARC Record:")
if dmarc['success']:
record = dmarc['record']
if len(record) > 60:
record = record[:60] + "..."
print(f" ✓ {record}")
else:
print(f" ✗ {dmarc['error']}")
print()
def display_ssl(report):
"""Display SSL section of the report."""
print("SSL Certificate")
print("-" * 40)
ssl_data = report.ssl
if not ssl_data['success']:
print(f"\n ✗ {ssl_data['error']}")
print()
return
if ssl_data['valid']:
print(f"\n ✓ Valid Certificate")
print(f" Issued to: {ssl_data['common_name']}")
print(f" Issued by: {ssl_data['issuer']}")
print(f" Valid from: {ssl_data['not_before']}")
print(f" Expires: {ssl_data['not_after']} ({ssl_data['days_until_expiry']} days)")
if ssl_data['expired']:
print(f" ⚠ CERTIFICATE EXPIRED!")
elif ssl_data['expiring_soon']:
print(f" ⚠ Expiring soon - renewal recommended")
if ssl_data.get('subject_alt_names'):
sans = ', '.join(ssl_data['subject_alt_names'][:3])
print(f" Also valid for: {sans}")
else:
print(f"\n ✗ {ssl_data['error']}")
print()
def display_headers(report):
"""Display HTTP headers section of the report."""
print("Security Headers")
print("-" * 40)
headers_data = report.headers
if not headers_data['success']:
print(f"\n ✗ {headers_data['error']}")
print()
return
print(f"\n URL: {headers_data['url']}")
print(f" Status: {headers_data['status_code']}")
print()
for header_name, header_info in headers_data['headers'].items():
if header_info['present']:
value = header_info['value']
if len(value) > 50:
value = value[:50] + "..."
print(f" ✓ {header_name}")
print(f" {value}")
else:
print(f" ✗ {header_name} - MISSING")
print(f" ({header_info['description']})")
print()
analyzer.py
#!/usr/bin/env python3
"""
Domain Security Analyzer
A tool to analyze domain security configurations including
DNS records, SSL certificates, and HTTP security headers.
Usage:
python analyzer.py <domain>
Example:
python analyzer.py github.com
"""
import sys
from scanner import Scanner
from models import ScanReport
import cli
def main():
"""Main entry point for the CLI."""
if len(sys.argv) != 2:
print("Usage: python analyzer.py <domain>")
print("Example: python analyzer.py github.com")
sys.exit(1)
domain = sys.argv[1]
# Run the scan
print(f"\nScanning {domain}...")
scanner = Scanner(domain)
results = scanner.scan()
# Create report from results
report = ScanReport(results)
# Display the report
cli.display_report(report)
if __name__ == "__main__":
main()
requirements.txt
dnspython==2.6.1
requests==2.31.0
Test It
Once you've created all the files (which we'll cover in detail in the next posts), run:
python analyzer.py github.com
You should see:
Scanning github.com...
============================================================
Security Report for github.com
Scanned at: 2024-12-25T16:15:00.501688+00:00
============================================================
DNS Records
----------------------------------------
A Records (IPv4):
→ 20.207.73.82
MX Records (Mail Servers):
→ [1] aspmx.l.google.com
→ [5] alt1.aspmx.l.google.com
...
SPF Record:
✓ v=spf1 ip4:192.30.252.0/22 include:_netblocks.google.com...
DMARC Record:
✓ v=DMARC1; p=reject; pct=100; rua=mailto:dmarc@github.com
SSL Certificate
----------------------------------------
✓ Valid Certificate
Issued to: github.com
Issued by: Sectigo Limited
Valid from: 2025-02-05
Expires: 2026-02-05 (42 days)
Also valid for: github.com, www.github.com
Security Headers
----------------------------------------
URL: https://github.com/
Status: 200
✓ Strict-Transport-Security
max-age=31536000; includeSubdomains; preload
✓ Content-Security-Policy
default-src 'none'; base-uri 'self'; child-src git...
✓ X-Frame-Options
deny
✓ X-Content-Type-Options
nosniff
✓ Referrer-Policy
origin-when-cross-origin, strict-origin-when-cross...
✗ Permissions-Policy - MISSING
(Controls browser feature access)
Same output as before — but now the code is organized for growth.
Why This Matters
With this structure, adding new features becomes easy.
Want a web interface? Create a Flask route that calls the Scanner and renders results as HTML instead of printing to terminal. The Scanner doesn't change — only the display layer.
Want an API? Same Scanner, but return JSON instead of HTML.
Want to save to database? The ScanReport has a to_dict() method ready for that.
One scanner. Multiple outputs. That's the power of separation.
What's Next
Each file will be explained in detail in future posts. After that, we'll build the scoring engine — turning all this data into letter grades and recommendations.
Get the Code
You can find all the restructured code on GitHub.
Or create the files yourself as we walk through them in the next posts.
Commit Your Progress
git add .
git commit -m "Restructure project for scalability"
git push origin main
See you in the next one.
This is Part 5 of the Domain Security Analyzer series.