Skip to main content

Command Palette

Search for a command to run...

Building a Domain Security Analyzer — The Entry Point

Published
10 min read

In the last post, we built the CLI module that formats and displays our scan results. Now we have all the pieces:

  • Scanner package — Fetches security data

  • Models package — Wraps data with convenient properties

  • CLI module — Displays results beautifully

Today we build the glue — the entry point that ties everything together.


Today's Goal

By the end of this post, you'll understand:

  1. What an entry point is — The first code that runs when users invoke your program

  2. What the shebang line does — Making scripts executable on Unix systems

  3. How sys.argv works — Reading command-line arguments

  4. How the data flows through our architecture — From user input to final output


The Complete Code

Here's the full analyzer.py file. It's intentionally short — all the heavy lifting happens in our packages:

#!/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()

Now let's understand every piece.


Part 1: The Shebang Line

#!/usr/bin/env python3

This line is called a "shebang" (from "hash-bang" — the #! characters). It's not Python code — it's an instruction to Unix-based operating systems (Linux, macOS).

What it does:

When you run a script directly:

./analyzer.py github.com

The operating system reads the first line to figure out how to run the file. The shebang says: "Find python3 using the env command, then use it to run this script."

Why /usr/bin/env python3 instead of just /usr/bin/python3?

Python might be installed in different locations:

/usr/bin/python3          # System Python on Linux
/usr/local/bin/python3    # Homebrew Python on macOS
/home/user/.pyenv/...     # pyenv-managed Python

The env command searches your PATH and finds Python wherever it's installed. This makes the script portable across different systems.

Windows note:

Windows ignores the shebang line. It uses file associations instead — .py files are associated with Python. But the line doesn't hurt, and it makes your code work on Linux/Mac too.


Part 2: The Module Docstring

"""
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
"""

This is a multi-line docstring at the top of the file. It serves multiple purposes:

  1. Documentation — Anyone reading the file immediately knows what it does

  2. Help text — Some tools extract this to show users (like pydoc analyzer)

  3. Usage instructions — Shows how to run the program with examples

Why include usage in the docstring?

Users often open the main file to see how to use a tool. Having usage right at the top means they don't have to hunt for it.


Part 3: The Imports

import sys
from scanner import Scanner
from models import ScanReport
import cli

Let's break down each import and what it brings:

import sys

The sys module is Python's interface to the interpreter and operating system. We use it for:

  • sys.argv — List of command-line arguments

  • sys.exit() — Exit the program with a status code

from scanner import Scanner

This imports the Scanner class from our scanner package. Remember how we set up scanner/__init__.py:

from .scanner import Scanner
__all__ = ['Scanner']

That __init__.py file makes this clean import possible. Without it, we'd need:

from scanner.scanner import Scanner  # Ugly

from models import ScanReport

Same pattern. The models/__init__.py exposes ScanReport, so we can import it directly from the package.

import cli

This imports the entire cli module. We use cli.display_report() rather than importing the function directly. Either approach works:

# Option 1: Import the module
import cli
cli.display_report(report)

# Option 2: Import the function
from cli import display_report
display_report(report)

We use Option 1 because it makes it clear where display_report comes from when reading the code.


Part 4: Argument Validation

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)

What is sys.argv?

When you run a program, everything you type becomes a list:

python analyzer.py github.com

Becomes:

sys.argv = ['analyzer.py', 'github.com']
#           [0]            [1]

The first element (sys.argv[0]) is always the script name. Arguments come after.

Why check len(sys.argv) != 2?

We expect exactly two items:

  1. The script name (always present)

  2. The domain to scan (user provides this)

If the length isn't 2, something's wrong:

python analyzer.py              # len = 1, missing domain
python analyzer.py a b c        # len = 4, too many arguments

What is sys.exit(1)?

Exit codes tell the operating system whether the program succeeded:

  • 0 = Success

  • Non-zero = Failure (by convention, 1 for general errors)

This matters for scripts and automation:

python analyzer.py github.com && echo "Success!"

The && only runs "Success!" if the first command exits with 0.


Part 5: Getting the Domain

    domain = sys.argv[1]

Simple — extract the domain from the argument list. After validation, we know sys.argv[1] exists.

No validation here?

We don't check if it's a valid domain format. The Scanner will handle that — if DNS lookup fails, we'll get an error in the results. This follows a principle: validate at the boundary where the data is used, not where it enters.


Part 6: Creating the Scanner

    # Run the scan
    print(f"\nScanning {domain}...")
    scanner = Scanner(domain)
    results = scanner.scan()

The progress message:

print(f"\nScanning {domain}...")

This tells users something is happening. Security scans take a few seconds (DNS lookups, SSL handshakes, HTTP requests). Without feedback, users might think the program froze.

The \n adds a blank line before the message for visual separation.

Creating the Scanner:

scanner = Scanner(domain)

This creates a Scanner instance. Remember from our Scanner class:

def __init__(self, domain):
    self.domain = domain.lower().strip()
    self.results = None
    self.scan_time = None

The constructor stores the domain (normalized to lowercase, whitespace removed) but doesn't do any scanning yet.

Running the scan:

results = scanner.scan()

This calls the scan() method, which:

  1. Records the current time

  2. Runs DNS checks via dns_scanner.scan_dns()

  3. Runs SSL checks via ssl_scanner.scan_ssl()

  4. Runs header checks via headers_scanner.scan_headers()

  5. Returns a dictionary with all results

The results variable now holds a nested dictionary like:

{
    'domain': 'github.com',
    'scan_time': '2024-12-25T16:15:00+00:00',
    'dns': { ... },
    'ssl': { ... },
    'headers': { ... }
}

Part 7: Creating the Report

    # Create report from results
    report = ScanReport(results)

The raw results dictionary works, but it's tedious to use:

# Without ScanReport
if results['ssl'].get('success') and results['ssl'].get('valid'):
    days = results['ssl']['days_until_expiry']

ScanReport wraps the dictionary and provides convenient properties:

# With ScanReport
if report.has_valid_ssl:
    days = report.ssl_days_remaining

The report doesn't duplicate data — it stores a reference to the results dictionary and provides cleaner access to it.


Part 8: Displaying the Report

    # Display the report
    cli.display_report(report)

This calls our CLI module's main display function. It:

  1. Prints the header with domain and timestamp

  2. Calls display_dns(report) for DNS section

  3. Calls display_ssl(report) for SSL section

  4. Calls display_headers(report) for headers section

Why pass the report, not results?

The cli module uses properties like report.has_valid_ssl and report.missing_headers. These come from ScanReport, not the raw dictionary. The report provides the interface that display functions expect.


Part 9: The Entry Point Guard

if __name__ == "__main__":
    main()

We covered this in Post 1, but let's revisit it in context.

The problem it solves:

When Python runs a file directly:

python analyzer.py github.com

It sets __name__ = "__main__", and main() runs.

When Python imports the file:

# In some test file
import analyzer
# We just want access to the code, not to run a scan

It sets __name__ = "analyzer", and main() does NOT run.

Why have a main() function at all?

We could put all the code directly under if __name__ == "__main__":, but a function is better:

  1. Testability — We can call main() from tests

  2. Clarity — The function name describes what happens

  3. Scope — Variables inside main() don't pollute the module namespace


Part 10: The Complete Data Flow

Let's trace what happens when a user runs our tool:

User types: python analyzer.py github.com
                    │
                    ▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. Python loads analyzer.py                                     │
│    - Imports sys, Scanner, ScanReport, cli                      │
│    - __name__ == "__main__", so main() runs                     │
└─────────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. main() validates arguments                                   │
│    - sys.argv = ['analyzer.py', 'github.com']                   │
│    - len(sys.argv) == 2, validation passes                      │
│    - domain = 'github.com'                                      │
└─────────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Scanner performs checks                                      │
│    - dns_scanner.scan_dns() → DNS records                       │
│    - ssl_scanner.scan_ssl() → Certificate info                  │
│    - headers_scanner.scan_headers() → Security headers          │
│    - Returns combined dictionary                                │
└─────────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. ScanReport wraps results                                     │
│    - Provides properties: has_valid_ssl, missing_headers, etc.  │
│    - Makes data easy to work with                               │
└─────────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. cli.display_report() shows output                            │
│    - Formats and prints each section                            │
│    - User sees the security report                              │
└─────────────────────────────────────────────────────────────────┘

This flow demonstrates separation of concerns:

  • analyzer.py — Orchestrates the process

  • Scanner — Gathers data

  • ScanReport — Provides data interface

  • cli — Displays data

Each piece has one job. If we want a web interface, we replace step 5 with HTML templates. The rest stays the same.


Part 11: Final Project Structure

Here's our complete project after this post:

src/
├── scanner/
│   ├── __init__.py         # Exposes Scanner class
│   ├── scanner.py          # Main Scanner class
│   ├── dns_scanner.py      # DNS record checks
│   ├── ssl_scanner.py      # SSL certificate checks
│   └── headers_scanner.py  # HTTP header checks
├── models/
│   ├── __init__.py         # Exposes ScanReport
│   └── report.py           # ScanReport class
├── cli.py                  # Terminal display functions
├── analyzer.py             # Entry point (this post)
├── requirements.txt        # Dependencies
└── venv/                   # Virtual environment

Layer responsibilities:

LayerFilesPurpose
Entryanalyzer.pyParse arguments, orchestrate flow
Displaycli.pyFormat and print output
Modelmodels/report.pyData access interface
Scannerscanner/*.pyPerform security checks

Part 12: Running the Complete Tool

From the src folder:

python analyzer.py github.com

Output:

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
    → [5] alt2.aspmx.l.google.com
    → [10] alt3.aspmx.l.google.com
    → [10] alt4.aspmx.l.google.com

  SPF Record:
    ✓ v=spf1 ip4:192.30.252.0/22 include:_netblocks.google.com inc...

  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-sr...
  ✓ 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)

Try other domains:

python analyzer.py example.com      # Simple site, fewer protections
python analyzer.py facebook.com     # Different security config
python analyzer.py expired.badssl.com  # Intentionally broken SSL

Summary: What We've Learned

  1. The shebang line (#!/usr/bin/env python3) — Tells Unix systems how to run the script directly

  2. sys.argv holds command-line arguments — sys.argv[0] is the script, sys.argv[1:] are user arguments

  3. sys.exit(code) signals success or failure — 0 for success, non-zero for errors

  4. The entry point orchestrates, doesn't implement — It connects pieces together without duplicating their logic

  5. Separation of concerns enables flexibility — Each layer has one job; replacing any layer doesn't affect others

  6. The if __name__ == "__main__" guard — Prevents main() from running when the file is imported


What's Next

We now have a complete, working domain security analyzer:

  • ✓ DNS record analysis

  • ✓ SSL certificate validation

  • ✓ HTTP security headers

  • ✓ Clean architecture

  • ✓ Beautiful terminal output

But users still have to interpret the results themselves. Is this domain secure? What should they fix first?

In the next post, we'll build the Scoring Engine — turning all this data into:

  • Letter grades (A+ to F) — Overall and per-category

  • Critical issues vs warnings — Priority ranking

  • Specific recommendations — What to fix and why

The raw data becomes actionable insight.


Commit Your Progress

git add .
git commit -m "Add entry point and complete CLI tool"
git push origin main

See you in the next one.


This is Part 13 of the Domain Security Analyzer series.

Find the code on GitHub.