Skip to main content

Command Palette

Search for a command to run...

Building a Domain Security Analyzer — The CLI Display Module

Published
16 min read

In the last post, we built the ScanReport class that wraps our raw scan data in convenient properties. Now we have clean access to our data — report.has_valid_ssl instead of nested dictionary lookups. But we still need to show this data to users.

Today we build the display layer — the module that turns a ScanReport into a beautiful terminal output.

Today's Goal

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

  1. Why display logic belongs in its own module — Separation of concerns in practice

  2. How to format terminal output — String multiplication, f-strings, and visual hierarchy

  3. How each display function works — Breaking down complex output into manageable pieces

  4. Why conditional formatting matters — Showing the right information in the right way


Part 1: Why Separate Display Logic?

Our project now has clear layers:

┌─────────────────────────────────────────────────────────────┐
│                      User Interface                          │
│                                                              │
│   cli.py ← We're building this today                        │
│   (Future: web templates, API responses, PDF generator)     │
│                                                              │
└─────────────────────────────────────────────────────────────┘
                            ↑
                    ScanReport object
                            ↑
┌─────────────────────────────────────────────────────────────┐
│                      Data Layer                              │
│                                                              │
│   models/report.py — Clean access to scan data              │
│                                                              │
└─────────────────────────────────────────────────────────────┘
                            ↑
                    Raw results dict
                            ↑
┌─────────────────────────────────────────────────────────────┐
│                     Scanner Layer                            │
│                                                              │
│   scanner/scanner.py — Coordinates all checks               │
│   scanner/dns_scanner.py — DNS lookups                      │
│   scanner/ssl_scanner.py — Certificate validation           │
│   scanner/headers_scanner.py — Security headers             │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Each layer has one job:

  • Scanner layer — Fetches data from the internet

  • Data layer — Organizes data for easy access

  • Display layer — Presents data to humans

The Problem with Mixed Concerns

Imagine if our scanner functions printed output directly:

def get_a_records(domain):
    print("Checking A records...")  # Display mixed with logic
    answers = resolver.resolve(domain, "A")
    for ip in answers:
        print(f"  Found: {ip}")  # More display code
    return [...]

This causes problems:

1. Can't reuse the scanner

Want to build a web interface? The print statements would pollute your HTTP response. Want to save results to a database? The prints would clutter your logs.

2. Can't change the format

Want JSON output? You'd have to modify every scanner function. Want a different visual style? Changes scattered everywhere.

3. Hard to test

Testing a function that prints is awkward. You'd have to capture stdout instead of just checking return values.

The Solution: Dedicated Display Module

By putting all display logic in cli.py, we get:

1. Reusable scanners

# CLI usage
results = scanner.scan()
report = ScanReport(results)
cli.display_report(report)  # Terminal output

# Web usage (future)
results = scanner.scan()
report = ScanReport(results)
return render_template('report.html', report=report)  # HTML output

# API usage (future)
results = scanner.scan()
return jsonify(report.to_dict())  # JSON output

Same scanner, different presentation.

2. Centralized formatting

All visual decisions — symbols, spacing, truncation — live in one place. Want to change to [PASS]? One file to edit.

3. Easy testing

Scanner tests check data. Display tests check formatting. Clean separation.


Part 2: The Complete Code

Here's the full cli.py file:

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

Now let's understand every piece.


Part 3: The Main Display Function

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)

What It Does

This is the entry point for displaying a report. It prints a header, then delegates to specialized functions for each section.

The Header Block

print(f"\n{'='*60}")
print(f"  Security Report for {report.domain}")
print(f"  Scanned at: {report.scan_time}")
print(f"{'='*60}\n")

Let's break down the syntax:

f"\n{'='*60}"

This is an f-string with an expression inside the braces.

  • \n — Newline character (blank line before the header)

  • '='*60 — String multiplication: repeats = sixty times

The result:

============================================================

Why 60 characters?

Terminal windows are typically 80 characters wide. A 60-character line leaves room for the content that follows while creating clear visual separation.

String multiplication

In Python, multiplying a string by a number repeats it:

'=' * 5   # '====='
'-' * 10  # '----------'
'ab' * 3  # 'ababab'

This is useful for creating visual dividers without typing 60 equal signs manually.

f" Security Report for {report.domain}"

Two spaces at the start indent the text, making it visually centered within the border.

Delegating to Section Functions

display_dns(report)
display_ssl(report)
display_headers(report)

Rather than putting all display logic in one giant function, we split it into sections. Each function handles one part of the report.

Why split into functions?

  1. Readability — Each function is short and focused

  2. Maintainability — Changing DNS display doesn't risk breaking SSL display

  3. Reusability — Could call display_ssl(report) alone if needed

  4. Testing — Can test each section independently

What the Output Looks Like

============================================================
  Security Report for github.com
  Scanned at: 2024-12-27T15:30:45.123456+00:00
============================================================

DNS Records
----------------------------------------
  ...

SSL Certificate
----------------------------------------
  ...

Security Headers
----------------------------------------
  ...

A clear visual hierarchy: main header, then sections with their own headers.


Part 4: The DNS Display Function

def display_dns(report):
    """Display DNS section of the report."""
    print("DNS Records")
    print("-" * 40)

The Section Header

print("DNS Records")
print("-" * 40)

Each section gets a title and a divider line. We use dashes (-) instead of equals (=) to create visual hierarchy — the main header uses =, section headers use -.

Displaying A Records

    # 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']}")

Extracting the data

a_records = report.dns['a_records']

We access the A records data from the report. This returns a dictionary like:

{'success': True, 'records': ['20.207.73.82']}
# or
{'success': False, 'error': 'Domain does not exist'}

The subsection label

print("\n  A Records (IPv4):")
  • \n — Blank line before this subsection

  • Two spaces — Indentation under the section header

  • (IPv4) — Helpful context for users who might not know what A records are

Conditional display

if a_records['success']:
    for ip in a_records['records']:
        print(f"    → {ip}")
else:
    print(f"    ✗ {a_records['error']}")

If the lookup succeeded, we loop through and print each IP address. If it failed, we print the error message.

The arrow symbol

print(f"    → {ip}")

The character creates a visual list. Four spaces indent it under the subsection label. The output looks like:

  A Records (IPv4):
    → 20.207.73.82
    → 140.82.112.3

The X symbol for errors

print(f"    ✗ {a_records['error']}")

The immediately signals something went wrong. Users don't have to read the message to know there's a problem.

Displaying MX Records

    # 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']}")

Same pattern as A records, but MX records have two pieces of information:

print(f"    → [{mx['priority']}] {mx['server']}")
  • [{mx['priority']}] — The priority number in brackets

  • {mx['server']} — The mail server hostname

Output:

  MX Records (Mail Servers):
    → [1] aspmx.l.google.com
    → [5] alt1.aspmx.l.google.com
    → [10] alt3.aspmx.l.google.com

The brackets around priority make it visually distinct from the server name.

Displaying SPF Records

    # 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']}")

Truncating long values

record = spf['record']
if len(record) > 60:
    record = record[:60] + "..."

SPF records can be very long — sometimes 200+ characters. Printing the full record would wrap awkwardly and make the output hard to read.

We truncate to 60 characters and add ... to indicate there's more. Users who need the full record can use other tools or check DNS directly.

The truncation logic

if len(record) > 60:        # Is it too long?
    record = record[:60]    # Take first 60 characters
    record = record + "..." # Add ellipsis

The slice record[:60] means "characters from the start up to (but not including) position 60" — the first 60 characters.

The checkmark for success

print(f"    ✓ {record}")

Unlike A and MX records where we use arrows for list items, SPF is a single record. The indicates "this record exists and was found."

Displaying DMARC Records

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

Same pattern as SPF — single record, truncation for long values, checkmark for success, X for failure.

The trailing print()

print()

Adds a blank line after the DNS section, creating visual separation before the next section.

Complete DNS Output Example

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.c...

  DMARC Record:
    ✓ v=DMARC1; p=reject; pct=100; rua=mailto:dmarc@github.com

Part 5: The SSL Display Function

def display_ssl(report):
    """Display SSL section of the report."""
    print("SSL Certificate")
    print("-" * 40)

    ssl_data = report.ssl

Extracting SSL Data

ssl_data = report.ssl

We store this in a local variable because we'll access it multiple times. This is slightly more efficient and makes the code cleaner than writing report.ssl everywhere.

Handling Connection Failures

    if not ssl_data['success']:
        print(f"\n  ✗ {ssl_data['error']}")
        print()
        return

Early return pattern

If we couldn't even connect to check the certificate (timeout, connection refused, DNS failure), there's nothing else to show. We print the error and return immediately.

The return statement exits the function early. Without it, the code would continue and try to access keys that don't exist in the error case.

Why check success first?

Remember our SSL scanner returns different structures:

# Success case
{'success': True, 'valid': True, 'common_name': '...', ...}

# Connection failed
{'success': False, 'error': 'Connection timed out'}

# Certificate invalid
{'success': True, 'valid': False, 'error': 'certificate has expired'}

We handle connection failures first because there's no certificate data to show.

Displaying Valid Certificates

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

The certificate summary

For valid certificates, we show the key information:

  • Issued to — The domain the certificate covers

  • Issued by — The Certificate Authority (Sectigo, Let's Encrypt, etc.)

  • Valid from — When the certificate became active

  • Expires — When it will expire, with days remaining for quick assessment

Including days remaining

print(f"    Expires: {ssl_data['not_after']} ({ssl_data['days_until_expiry']} days)")

The date alone (2026-02-05) requires mental math. Adding (405 days) gives immediate context — users know at a glance whether renewal is urgent.

Warning Flags

        if ssl_data['expired']:
            print(f"    ⚠ CERTIFICATE EXPIRED!")
        elif ssl_data['expiring_soon']:
            print(f"    ⚠ Expiring soon - renewal recommended")

The warning symbol

The character draws attention to important issues. We use it sparingly — only for things that need action.

The elif logic

A certificate can't be both expired AND expiring soon. If it's expired, days_until_expiry is negative. If it's expiring soon, it's between 0 and 30 days. We check expired first because it's the more severe condition.

ALL CAPS for critical issues

print(f"    ⚠ CERTIFICATE EXPIRED!")

We use uppercase for CERTIFICATE EXPIRED! to make it unmissable. An expired certificate breaks HTTPS entirely — users need to notice this immediately.

Subject Alternative Names

        if ssl_data.get('subject_alt_names'):
            sans = ', '.join(ssl_data['subject_alt_names'][:3])
            print(f"    Also valid for: {sans}")

Checking if SANs exist

if ssl_data.get('subject_alt_names'):

Some certificates only cover one domain. We use .get() with no default (returns None if missing) and rely on Python's truthiness — an empty list or None is falsy.

Joining the list

sans = ', '.join(ssl_data['subject_alt_names'][:3])
  • ssl_data['subject_alt_names'][:3] — First three SANs (avoid long lists)

  • ', '.join(...) — Combine into comma-separated string

Result: github.com, www.github.com, *.github.com

Displaying Invalid Certificates

    else:
        print(f"\n  ✗ {ssl_data['error']}")

    print()

If valid is False, we show the error. This could be:

  • "certificate has expired"

  • "self-signed certificate"

  • "hostname mismatch"

The specific error helps users understand what's wrong.

Complete SSL Output Examples

Valid certificate:

SSL Certificate
----------------------------------------

  ✓ Valid Certificate
    Issued to: github.com
    Issued by: Sectigo Limited
    Valid from: 2025-02-05
    Expires: 2026-02-05 (405 days)
    Also valid for: github.com, www.github.com

Expired certificate:

SSL Certificate
----------------------------------------

  ✗ Certificate verification failed: certificate has expired

Connection failed:

SSL Certificate
----------------------------------------

  ✗ Connection timed out

Part 6: The Headers Display Function

def display_headers(report):
    """Display HTTP headers section of the report."""
    print("Security Headers")
    print("-" * 40)

    headers_data = report.headers

Handling Request Failures

    if not headers_data['success']:
        print(f"\n  ✗ {headers_data['error']}")
        print()
        return

Same early return pattern as SSL. If we couldn't fetch the page, we can't check headers.

Showing Request Details

    print(f"\n  URL: {headers_data['url']}")
    print(f"  Status: {headers_data['status_code']}")
    print()

Why show the URL?

The user might have typed github.com, but we actually checked https://github.com/. After redirects, we might end up at https://www.github.com/. Showing the final URL clarifies exactly what was checked.

Why show the status code?

A 200 status means we got the real page. A 403 might mean we were blocked. A 500 means the server had an error. This context helps interpret the header results.

Looping Through Headers

    for header_name, header_info in headers_data['headers'].items():

The headers data looks like:

{
    'Strict-Transport-Security': {'present': True, 'value': 'max-age=...'},
    'Content-Security-Policy': {'present': True, 'value': "default-src..."},
    'X-Frame-Options': {'present': False, 'value': None, 'description': '...'},
    ...
}

We loop through each header name and its associated information.

Displaying Present Headers

        if header_info['present']:
            value = header_info['value']
            if len(value) > 50:
                value = value[:50] + "..."
            print(f"  ✓ {header_name}")
            print(f"      {value}")

Truncating long header values

Security headers like Content-Security-Policy can be hundreds of characters. We truncate to 50 characters to keep output readable.

Two-line format

  ✓ Strict-Transport-Security
      max-age=31536000; includeSubdomains; preload

The header name on one line, the value indented below. This handles long header names without awkward wrapping.

Displaying Missing Headers

        else:
            print(f"  ✗ {header_name} - MISSING")
            print(f"      ({header_info['description']})")

Explicit "MISSING" label

  ✗ Permissions-Policy - MISSING

We don't just show an X — we explicitly say "MISSING" so users know the header doesn't exist (vs. existing but having a problem).

Including the description

      (Controls browser feature access)

When a header is missing, users might not know what it does. The description explains why they should care.

Complete Headers Output Example

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
      strict-origin-when-cross-origin
  ✗ Permissions-Policy - MISSING
      (Controls browser feature access)

Part 7: Visual Design Principles

Let's step back and look at the design decisions that make this output readable.

Visual Hierarchy

============================================================  ← Heavy border (=)
  Security Report for github.com                              ← Title
============================================================

DNS Records                                                   ← Section name
----------------------------------------                      ← Light border (-)

  A Records (IPv4):                                           ← Subsection
    → 20.207.73.82                                            ← Data item

Three levels of visual weight:

  1. Double line (=) — Main header, appears once

  2. Single line (-) — Section headers, appear for each section

  3. Symbols (, , ) — Individual items

Users can scan the output quickly and find what they need.

Indentation Creates Structure

DNS Records              ← No indent (section level)
  A Records (IPv4):      ← 2 spaces (subsection level)
    → 20.207.73.82       ← 4 spaces (item level)

Consistent indentation shows relationships. Items under a subsection are clearly grouped.

Symbols Convey Status

SymbolMeaningUsed For
Success/PresentFound records, valid certificates, present headers
Failure/MissingErrors, missing records, missing headers
List itemMultiple items (IP addresses, mail servers)
WarningExpiring certificates, important issues

Users learn the symbols once and can scan results quickly.

Truncation Keeps Output Readable

Long values break visual flow:

  ✓ Content-Security-Policy
      default-src 'none'; base-uri 'self'; child-src github.com/assets-cdn/worker/ github.com/webpack/; connect-src 'self' uploads.github.com objects-origin.githubusercontent.com...

Truncation maintains clean lines:

  ✓ Content-Security-Policy
      default-src 'none'; base-uri 'self'; child-sr...

Users who need full values can use browser dev tools or other methods.


Part 8: How CLI Fits Into the Project

Here's our updated project structure:

src/
├── scanner/
│   ├── __init__.py
│   ├── scanner.py
│   ├── dns_scanner.py
│   ├── ssl_scanner.py
│   └── headers_scanner.py
├── models/
│   ├── __init__.py
│   └── report.py
├── cli.py                  ← Display layer (this post)
└── analyzer.py             ← Entry point (next post)

The Data Flow

┌─────────────┐
│ analyzer.py │  User runs: python analyzer.py github.com
└──────┬──────┘
       │ Creates Scanner, calls scan()
       ▼
┌─────────────┐
│   Scanner   │  Coordinates dns_scanner, ssl_scanner, headers_scanner
└──────┬──────┘
       │ Returns raw dict
       ▼
┌─────────────┐
│ ScanReport  │  Wraps dict with convenient properties
└──────┬──────┘
       │ Passed to display functions
       ▼
┌─────────────┐
│    cli.py   │  Formats and prints to terminal
└─────────────┘

Why This Architecture Works

Adding a web interface later:

# web/views.py (future)
from scanner import Scanner
from models import ScanReport

@app.route('/scan/<domain>')
def scan_domain(domain):
    scanner = Scanner(domain)
    report = ScanReport(scanner.scan())
    return render_template('report.html', report=report)

The scanner and model layers stay the same. We just add a new display layer (templates instead of print statements).

Adding an API later:

# api/routes.py (future)
from scanner import Scanner
from models import ScanReport

@app.route('/api/scan/<domain>')
def api_scan(domain):
    scanner = Scanner(domain)
    report = ScanReport(scanner.scan())
    return jsonify(report.to_dict())

Same scanner, same model, different output format.


Part 9: Using the CLI Module

Here's how it all comes together:

from scanner import Scanner
from models import ScanReport
import cli

# Create and run scanner
scanner = Scanner("github.com")
results = scanner.scan()

# Wrap in report
report = ScanReport(results)

# Display to terminal
cli.display_report(report)

Or display just one section:

cli.display_ssl(report)   # Only SSL info
cli.display_headers(report)  # Only headers

Summary: What We've Learned

  1. Display logic belongs in its own module — Separating display from data/logic enables multiple output formats (CLI, web, API, PDF)

  2. String multiplication creates dividers'=' * 60 is cleaner than typing 60 characters

  3. Early returns simplify error handling — Check for failures first and return, then handle the success case

  4. Visual hierarchy aids scanning — Different border weights, consistent indentation, and meaningful symbols help users find information quickly

  5. Truncation keeps output readable — Long values break visual flow; showing partial content with ... is better

  6. Functions can delegatedisplay_report calls specialized functions for each section, keeping code organized


What's Next

We have all the pieces:

  • Scanner package — Fetches security data

  • Models package — Organizes data for easy access

  • CLI module — Displays results beautifully

In the next post, we'll build analyzer.py — the entry point that ties everything together. It will:

  • Parse command-line arguments

  • Create the Scanner

  • Build the ScanReport

  • Call the CLI display functions

After that, we'll build the scoring engine — turning all this data into letter grades (A+ through F) with specific recommendations.


This is Part 12 of the Domain Security Analyzer series.

Find the code on GitHub.