Build a TCP Port Scanner in Python

Build a multi-threaded TCP port scanner with service detection in Python.

Introduction

30 Second Summary

Every time you visit a website, your browser connects to a specific port on a server. Network security professionals use port scanners to discover which ports are open on a system and what services are listening behind them.

In this project, you will build a TCP port scanner in Python that detects open ports, identifies running services through banner grabbing, and produces a formatted report of its findings.

What You'll Build

A Python CLI tool called portscanner.py that scans a target host for open TCP ports, fingerprints services via banner grabbing, handles SSL/TLS connections, and performs DNS resolution.

By the end of this project, you'll have:

  • A multi-threaded scanning engine that checks 1024 ports in seconds using Python's standard library.
  • A service detection system that identifies running services through banner grabbing and version extraction.
  • A polished CLI tool with DNS resolution, configurable options, and a formatted results table.
  • Secret Mission: Export scan results to a structured JSON report with timestamps and target metadata.

Are there any prerequisites?

You should be comfortable with Python basics including functions, loops, imports, and exception handling. No additional installs are needed since this project uses only Python's standard library.

Before We Start

Before you write any scanning logic, you need to confirm your environment is ready.

This project uses only Python's standard library, so there are no packages to install. You just need Python 3 itself, a project folder, and a working internet connection to reach your test target.

In this step, get ready to:
  • Confirm Python 3 is installed and create your project directory.
  • Verify all required standard library modules are importable.
  • Test connectivity to the authorized scan target.
Verify your Python installation and create the project directory

Your scanner is written entirely in Python 3. Let's confirm it's available on your machine.

  • Press Cmd+Space to open Spotlight search.
  • Type Terminal and press Enter to open it.
  • Check your Python version by running this command:
python3 --version

You should see a version number like Python 3.11.x or similar printed in your terminal.

Don't see a Python version?

If you see command not found, Python 3 is not installed or not on your PATH. Visit python.org/downloads to download and install the latest Python 3 release for macOS.

help me install Python 3 on macOS

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir port-scanner && cd port-scanner
  • Create the main script file by running this command:
touch portscanner.py
  • Confirm the files exist by running:
ls

You should see portscanner.py listed in the output.

Don't see the file?

Make sure you ran both mkdir and cd before running touch. If you skipped the cd step, the file was created in the wrong directory.

help me find where my portscanner.py file was created

Verify the standard library modules are available

Your port scanner relies on six modules from Python's standard library. No pip install needed.

  • Confirm all required modules import successfully by running this command:
python3 -c "import socket, ssl, concurrent.futures, re, argparse, json; print('All modules available')"

You should see All modules available printed with no errors.

What are these modules for?

  • socket handles raw TCP connections to remote ports.
  • ssl wraps connections in TLS encryption for probing HTTPS services.
  • concurrent.futures provides multi-threaded scanning for speed.
  • re extracts version strings from service banners using regex.
  • argparse builds the command-line interface.
  • json enables structured report export.

Seeing a ModuleNotFoundError?

All six modules ship with every standard Python 3 installation. If any import fails, your Python installation may be incomplete or corrupted. Reinstall Python 3 from python.org/downloads.

help me fix my Python installation so all standard library modules are available

Understand the test target

Port scanning random servers without permission is unethical and potentially illegal. For this project, you will use scanme.nmap.org, a host provided by the Nmap Project specifically for testing port scanners.

What is scanme.nmap.org?

The Nmap Project maintains this server and explicitly authorizes scanning with port scanners. Their policy states: a few scans per day is fine. Do not hammer the server with hundreds of scans, and do not use it for exploit testing or denial of service.

  • Test that your machine can resolve the target's hostname by running this command:
python3 -c "import socket; print(socket.gethostbyname('scanme.nmap.org'))"

You should see an IP address printed (something like 45.33.32.156 or similar). This confirms DNS resolution is working and your internet connection can reach the target.

Seeing a gaierror or no output?

This means DNS resolution failed. Check that you are connected to the internet and that no firewall or VPN is blocking DNS queries. Try opening scanme.nmap.org in your web browser to confirm it is reachable.

help me troubleshoot why scanme.nmap.org won't resolve

Your environment is confirmed and ready. Next up, you will write the core scanning engine that opens TCP connections to detect open ports.

Build the Core Scanner

Your project directory is set up and Python is ready to go. Now it's time to build the engine that actually scans ports.

A TCP connect scan works by attempting a full connection to each port on a target host. If the connection succeeds, the port is open. If it fails, the port is closed or filtered. You will combine this technique with multi-threading to scan over a thousand ports in seconds.

In this step, get ready to:
  • Write a TCP connect scan function that tests individual ports.
  • Add multi-threaded scanning with ThreadPoolExecutor for speed.
  • Test the scanner against a live target and confirm it finds open ports.
Create the TCP connect scan function

Every port scanner needs a way to test whether a single port is open. Your function will create a TCP socket, set a timeout, and use connect_ex() to attempt a connection without raising exceptions.

  • Open portscanner.py in your code editor and add the file header, imports, and port configuration by pasting this code:
#!/usr/bin/env python3
"""TCP Port Scanner with Service Detection."""

import socket
import ssl
import concurrent.futures
import re
import argparse
import json
import time
from datetime import datetime


# Well-known port numbers mapped to service names
COMMON_SERVICES = {
    21: "FTP",
    22: "SSH",
    25: "SMTP",
    53: "DNS",
    80: "HTTP",
    110: "POP3",
    143: "IMAP",
    443: "HTTPS",
    465: "SMTPS",
    993: "IMAPS",
    995: "POP3S",
    3306: "MySQL",
    5432: "PostgreSQL",
    6379: "Redis",
    8080: "HTTP-Alt",
    8443: "HTTPS-Alt",
}

# Ports that use TLS/SSL encryption
TLS_PORTS = {443, 8443, 993, 995, 465}

# Ports that speak HTTP and need an active probe
HTTP_PORTS = {80, 8080, 8443, 443}

What does this code do?

The shebang line (#!/usr/bin/env python3) lets the script run directly from the terminal without typing python3 first.

The imports pull in everything from Python's standard library that you will use across the project. COMMON_SERVICES maps well-known port numbers to human-readable service names. TLS_PORTS and HTTP_PORTS tell the scanner which ports need special handling later.

You won't see a change yet. The next code block uses these imports and constants.

  • Below the HTTP_PORTS line, add the scan_port function by pasting this code:


def scan_port(host, port, timeout):
    """Scan a single port. Returns port number if open, None if closed."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    try:
        # connect_ex returns 0 for success (open), non-zero for failure (closed)
        result = sock.connect_ex((host, port))
        if result == 0:
            return port
        return None
    except socket.error:
        return None
    finally:
        sock.close()

What does this code do?

socket.socket(socket.AF_INET, socket.SOCK_STREAM) creates a TCP socket for IPv4 connections. settimeout() prevents the scan from hanging on unresponsive ports.

connect_ex() attempts a full TCP connection and returns 0 if the port is open, or a non-zero error number if closed. The finally block ensures the socket is always closed, even if an error occurs.

  • Save portscanner.py.
  • Test the function by running this command:
python3 -c "from portscanner import scan_port; print(scan_port('scanme.nmap.org', 80, 2.0))"

You should see 80 printed, confirming port 80 is open on the test target.

Seeing None instead of 80?

If you see None, the connection timed out. Check that you have internet access and that scanme.nmap.org resolves correctly.

If you see a ModuleNotFoundError, make sure your terminal is inside the port-scanner directory where portscanner.py lives.

help me debug my scan_port function

Add multi-threaded scanning with ThreadPoolExecutor

Scanning 1024 ports one at a time would take over 15 minutes with a 1-second timeout per port. ThreadPoolExecutor lets you scan 100 ports simultaneously, turning minutes into seconds.

  • Below the scan_port function, add the scan_host function by pasting this code:


def scan_host(host, ports, max_threads, timeout):
    """Scan multiple ports concurrently. Returns sorted list of open ports."""
    open_ports = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
        # Submit a scan task for each port
        future_to_port = {
            executor.submit(scan_port, host, port, timeout): port
            for port in ports
        }
        # Collect results as they complete
        for future in concurrent.futures.as_completed(future_to_port):
            result = future.result()
            if result is not None:
                open_ports.append(result)
    return sorted(open_ports)

What does this code do?

ThreadPoolExecutor(max_workers=max_threads) creates a pool of 100 worker threads. Each thread picks up a port to scan, so 100 ports are tested at the same time.

executor.submit() schedules each port scan and returns a Future object. as_completed() yields results as soon as each scan finishes, regardless of submission order. The final sorted() call puts the open ports back in numerical order.

Why threads instead of asyncio?

Port scanning is I/O-bound. Each thread spends most of its time waiting for the network, not doing computation. Python's GIL does not block I/O waits, so threads give full parallelism here.

ThreadPoolExecutor is also simpler to reason about than asyncio for this use case, while delivering more than enough speed for scanning 1024 ports.

  • Save portscanner.py.

You won't see a change yet. The next chunk adds a test block that runs the scanner.

Seeing a syntax error on save?

Check that the scan_host function starts two blank lines below the end of scan_port. Python expects two blank lines between top-level definitions.

Also verify that the dictionary comprehension inside future_to_port uses curly braces {}, not square brackets.

help me fix a syntax error in my scan_host function

Test the basic scanner

Time to run the scanner against a real target. You will add a temporary test block that scans scanme.nmap.org on ports 1 through 1024.

  • At the very bottom of portscanner.py, add the test block by pasting this code:


if __name__ == "__main__":
    open_ports = scan_host("scanme.nmap.org", range(1, 1025), 100, 1.0)
    print("Open ports:", open_ports)

What does this code do?

The if __name__ == "__main__" guard ensures this code only runs when you execute the file directly, not when importing it as a module.

It calls scan_host with 100 threads and a 1-second timeout, scanning all 1024 standard ports. The result is a sorted list of open port numbers.

  • Save portscanner.py.
  • Run the scanner by executing this command:
python3 portscanner.py

You should see output like Open ports: [22, 80] (port numbers may vary slightly depending on the host's current configuration). The scan should complete in under 10 seconds.

Scan taking longer than 30 seconds?

If the scan is very slow, your firewall or network might be blocking outbound connections. Try reducing the port range to confirm the scanner works:

open_ports = scan_host("scanme.nmap.org", range(1, 101), 100, 1.0)

If you see no open ports at all, check that scanme.nmap.org is reachable by running ping scanme.nmap.org in your terminal.

help me troubleshoot why my port scanner is not finding open ports

What is scanme.nmap.org?

This is a host provided by the Nmap Project specifically for testing port scanners. You are authorized to scan it a few times per day. Do not hammer the server or attempt any exploit testing.

✔️ Awesome, I've got everything!

Great. Double check you have saved portscanner.py and your scan returned at least port 22 and 80 as open.

ⓧ I'd like to double check the full code

Here is the complete portscanner.py at the end of this step:

#!/usr/bin/env python3
"""TCP Port Scanner with Service Detection."""

import socket
import ssl
import concurrent.futures
import re
import argparse
import json
import time
from datetime import datetime


COMMON_SERVICES = {
    21: "FTP",
    22: "SSH",
    25: "SMTP",
    53: "DNS",
    80: "HTTP",
    110: "POP3",
    143: "IMAP",
    443: "HTTPS",
    465: "SMTPS",
    993: "IMAPS",
    995: "POP3S",
    3306: "MySQL",
    5432: "PostgreSQL",
    6379: "Redis",
    8080: "HTTP-Alt",
    8443: "HTTPS-Alt",
}

TLS_PORTS = {443, 8443, 993, 995, 465}

HTTP_PORTS = {80, 8080, 8443, 443}


def scan_port(host, port, timeout):
    """Scan a single port. Returns port number if open, None if closed."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    try:
        result = sock.connect_ex((host, port))
        if result == 0:
            return port
        return None
    except socket.error:
        return None
    finally:
        sock.close()


def scan_host(host, ports, max_threads, timeout):
    """Scan multiple ports concurrently. Returns sorted list of open ports."""
    open_ports = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
        future_to_port = {
            executor.submit(scan_port, host, port, timeout): port
            for port in ports
        }
        for future in concurrent.futures.as_completed(future_to_port):
            result = future.result()
            if result is not None:
                open_ports.append(result)
    return sorted(open_ports)


if __name__ == "__main__":
    open_ports = scan_host("scanme.nmap.org", range(1, 1025), 100, 1.0)
    print("Open ports:", open_ports)

Your core scanner is working. Next up, you will teach it to identify what services are actually running behind those open ports using banner grabbing and SSL connections.

Add Service Detection with Banner Grabbing

Your port scanner can already find open ports in seconds. But knowing a port is open is only half the story.

In this step, you'll add service detection. Your scanner will connect to each open port, read the banner the service sends back, and extract version information from it.

In this step, get ready to:
  • Build a banner grabber that reads service greetings from open ports.
  • Add SSL/TLS support to fingerprint encrypted services.
  • Extract service versions using regex pattern matching.
Build the banner grabber

When you connect to a network service, many services immediately send back a text greeting called a banner. SSH servers announce their version, FTP servers say "ready", and SMTP servers identify themselves.

Your banner grabber will connect to an open port and read whatever the service sends. For web servers, it needs to send a request first since HTTP doesn't send a greeting on its own.

  • In portscanner.py, find the end of the scan_host() function (after the return sorted(open_ports) line).
  • Add the following grab_banner() function below it:
def grab_banner(host, port, timeout):
    """Grab banner from a non-TLS service."""
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect((host, port))
        # Send HTTP HEAD request for web ports (they don't send banners on their own)
        if port in HTTP_PORTS:
            request = f"HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n"
            sock.sendall(request.encode())
        # Read whatever the service sends back
        banner = sock.recv(1024).decode(errors="replace").strip()
        sock.close()
        return banner
    except (socket.timeout, socket.error, OSError):
        return None

What does this code do?

  • The function connects to a specific port just like scan_port() does, but instead of just checking if the connection succeeds, it stays connected and listens.
  • For HTTP ports (defined in HTTP_PORTS), it sends a HEAD request first. The HEAD method returns headers (including the Server header) without downloading an entire page body.
  • For all other ports (SSH, FTP, SMTP), it just waits. These services send a greeting automatically on connection.
  • The decode(errors="replace") handles binary data gracefully by substituting unreadable bytes instead of crashing.
  • Save portscanner.py.
  • Test the banner grabber by running this command:
python3 -c "from portscanner import grab_banner; print(grab_banner('scanme.nmap.org', 22, 3.0))"

You should see a string starting with SSH-2.0-OpenSSH followed by a version number. This is the SSH server's banner.

Not seeing an SSH banner?

  • Make sure you are in the port-scanner directory where portscanner.py lives. Python needs to find the file to import it.
  • If you see None, your network may be blocking outbound connections on port 22. Try testing port 80 instead: replace 22 with 80 in the command above.
  • If you get an ImportError, double check that the function name grab_banner matches exactly (no typos, no capital letters).

help me debug why my grab_banner function isn't returning a banner from scanme.nmap.org

Add SSL/TLS support for encrypted ports

Many modern services run behind SSL/TLS encryption. Ports like 443 (HTTPS), 993 (IMAPS), and 995 (POP3S) require a TLS handshake before you can read any data.

Without TLS support, your scanner would get nothing from these ports. The grab_banner_ssl() function wraps the connection in a TLS layer so it can read encrypted service banners.

  • In portscanner.py, add the following function directly below grab_banner():
def grab_banner_ssl(host, port, timeout):
    """Grab banner from a TLS-wrapped service."""
    try:
        context = ssl.create_default_context()
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect((host, port))
        # Wrap the raw socket in TLS encryption
        ssock = context.wrap_socket(sock, server_hostname=host)
        # Send HTTP HEAD for web ports, same as non-TLS version
        if port in HTTP_PORTS:
            request = f"HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n"
            ssock.sendall(request.encode())
        banner = ssock.recv(1024).decode(errors="replace").strip()
        ssock.close()
        return banner
    except (socket.timeout, socket.error, ssl.SSLError, OSError):
        return None

What does this code do?

  • It creates a TLS context with ssl.create_default_context(), which provides secure default settings.
  • It disables hostname and certificate verification because this is a scanner, not a browser. You are reading banners, not authenticating trust.
  • It wraps the raw TCP socket in TLS using context.wrap_socket(sock, server_hostname=host). After wrapping, all reads and writes go through the encrypted channel.
  • The exception handler also catches ssl.SSLError to handle TLS handshake failures gracefully.

Why disable certificate verification?

A normal HTTPS client (like your browser) verifies that the server's certificate is valid and matches the hostname. This prevents man-in-the-middle attacks.

A port scanner has a different goal. You are connecting to arbitrary services to read their banners, not to trust them with sensitive data. Disabling verification lets you scan any TLS-wrapped port without the connection failing due to self-signed or expired certificates.

  • Save portscanner.py.

You won't see a change yet. The next function (identify_service) routes ports to either grab_banner() or grab_banner_ssl() depending on whether TLS is needed.

Seeing a syntax error on save?

  • Check that the f-string on the request line uses regular double quotes inside, not curly-quote characters copied from a PDF or web page.
  • Make sure grab_banner_ssl() is at the same indentation level as grab_banner() above it (not nested inside it).

help me fix a syntax error in my grab_banner_ssl function

Extract service versions with regex

Banners contain useful text, but they are messy. An SSH banner might read SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6. You want to pull out just the version identifier.

The extract_version() function uses regex patterns to find known version strings inside banner text.

  • In portscanner.py, add the following function below grab_banner_ssl():
def extract_version(banner):
    """Extract service version from banner text."""
    if not banner:
        return None
    # Patterns to match common service version strings
    patterns = [
        r"(OpenSSH[_\s][\d.]+\w*)",
        r"(Apache/[\d.]+)",
        r"(nginx/[\d.]+)",
        r"Server:\s*(.+)",
    ]
    for pattern in patterns:
        match = re.search(pattern, banner)
        if match:
            return match.group(1)
    return None

What does this code do?

  • Each regex pattern targets a specific service format. OpenSSH[_\s][\d.]+\w* matches SSH version strings like OpenSSH_8.9p1.
  • The function tries each pattern in order and returns the first match. More specific patterns (OpenSSH, Apache, nginx) come before the generic Server header pattern.
  • If no pattern matches, it returns None to indicate the service could not be fingerprinted.

You won't see a change yet. The next function ties everything together.

Now you need one more function that orchestrates the detection process. The identify_service() function checks whether a port needs TLS, grabs the banner using the right method, extracts the version, and looks up the service name from the COMMON_SERVICES dictionary you already have.

  • Add the identify_service() function directly below extract_version():
def identify_service(host, port, timeout):
    """Identify service running on an open port."""
    # Look up the well-known service name for this port number
    service_name = COMMON_SERVICES.get(port, "unknown")
    # Use TLS banner grabbing for encrypted ports, plain for everything else
    if port in TLS_PORTS:
        banner = grab_banner_ssl(host, port, timeout + 2)
    else:
        banner = grab_banner(host, port, timeout + 2)
    # Try to extract a version string from the raw banner
    version = extract_version(banner)
    return {
        "port": port,
        "state": "open",
        "service": service_name,
        "banner": banner[:120] if banner else None,
        "version": version,
    }

What does this code do?

  • It checks COMMON_SERVICES for a human-readable name (e.g., port 22 maps to "SSH"). If the port isn't in the dictionary, it falls back to "unknown".
  • It routes to grab_banner_ssl() or grab_banner() depending on whether the port is in TLS_PORTS.
  • The timeout gets an extra 2 seconds (timeout + 2) because banner grabbing involves waiting for the service to respond, which takes longer than a simple port check.
  • The banner is truncated to 120 characters to keep results clean and avoid storing massive HTTP responses.
  • Save portscanner.py.
  • Test the full service detection pipeline by running this command:
python3 -c "from portscanner import identify_service; import json; print(json.dumps(identify_service('scanme.nmap.org', 22, 1.0), indent=2))"

You should see a JSON object with "port": 22, "service": "SSH", and a "version" field showing the OpenSSH version string.

Getting None for version or banner?

  • If banner is null, your network might be blocking the connection or the service is too slow. Try increasing the timeout to 3.0 in the test command.
  • If version is null but banner has text, the regex patterns might not match the specific banner format. Check that your extract_version() patterns match the code above exactly.
  • If you see an ImportError for identify_service, make sure the function is defined at the module level (not indented inside another function).

help me debug why identify_service returns null for the version or banner fields

✔️ Awesome, I've got everything!

Great. Double check you've saved portscanner.py and that your test command returned service info for port 22.

ⓧ I'd like to double check the full code

Here is the complete portscanner.py file as it should look at the end of this step:

#!/usr/bin/env python3
"""TCP Port Scanner with Service Detection."""

import socket
import ssl
import concurrent.futures
import re
import argparse
import json
import time
from datetime import datetime


COMMON_SERVICES = {
    21: "FTP",
    22: "SSH",
    25: "SMTP",
    53: "DNS",
    80: "HTTP",
    110: "POP3",
    143: "IMAP",
    443: "HTTPS",
    465: "SMTPS",
    993: "IMAPS",
    995: "POP3S",
    3306: "MySQL",
    5432: "PostgreSQL",
    6379: "Redis",
    8080: "HTTP-Alt",
    8443: "HTTPS-Alt",
}

TLS_PORTS = {443, 8443, 993, 995, 465}

HTTP_PORTS = {80, 8080, 8443, 443}


def scan_port(host, port, timeout):
    """Scan a single port. Returns port number if open, None if closed."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    try:
        result = sock.connect_ex((host, port))
        if result == 0:
            return port
        return None
    except socket.error:
        return None
    finally:
        sock.close()


def scan_host(host, ports, max_threads, timeout):
    """Scan multiple ports concurrently. Returns sorted list of open ports."""
    open_ports = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
        future_to_port = {
            executor.submit(scan_port, host, port, timeout): port
            for port in ports
        }
        for future in concurrent.futures.as_completed(future_to_port):
            result = future.result()
            if result is not None:
                open_ports.append(result)
    return sorted(open_ports)


def grab_banner(host, port, timeout):
    """Grab banner from a non-TLS service."""
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect((host, port))
        if port in HTTP_PORTS:
            request = f"HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n"
            sock.sendall(request.encode())
        banner = sock.recv(1024).decode(errors="replace").strip()
        sock.close()
        return banner
    except (socket.timeout, socket.error, OSError):
        return None


def grab_banner_ssl(host, port, timeout):
    """Grab banner from a TLS-wrapped service."""
    try:
        context = ssl.create_default_context()
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect((host, port))
        ssock = context.wrap_socket(sock, server_hostname=host)
        if port in HTTP_PORTS:
            request = f"HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n"
            ssock.sendall(request.encode())
        banner = ssock.recv(1024).decode(errors="replace").strip()
        ssock.close()
        return banner
    except (socket.timeout, socket.error, ssl.SSLError, OSError):
        return None


def extract_version(banner):
    """Extract service version from banner text."""
    if not banner:
        return None
    patterns = [
        r"(OpenSSH[_\s][\d.]+\w*)",
        r"(Apache/[\d.]+)",
        r"(nginx/[\d.]+)",
        r"Server:\s*(.+)",
    ]
    for pattern in patterns:
        match = re.search(pattern, banner)
        if match:
            return match.group(1)
    return None


def identify_service(host, port, timeout):
    """Identify service running on an open port."""
    service_name = COMMON_SERVICES.get(port, "unknown")
    if port in TLS_PORTS:
        banner = grab_banner_ssl(host, port, timeout + 2)
    else:
        banner = grab_banner(host, port, timeout + 2)
    version = extract_version(banner)
    return {
        "port": port,
        "state": "open",
        "service": service_name,
        "banner": banner[:120] if banner else None,
        "version": version,
    }


if __name__ == "__main__":
    open_ports = scan_host("scanme.nmap.org", range(1, 1025), 100, 1.0)
    print("Open ports:", open_ports)

Your scanner can now identify services and pull version information from open ports. Next up, you'll add DNS resolution and build a polished CLI interface with formatted output.

Add DNS Resolution and the CLI Interface

Your scanner now detects services and extracts version info from banners. That is the core intelligence.

In this step, you will wrap that intelligence in a proper command-line interface. You will add DNS resolution so the scanner can translate hostnames to IPs (and vice versa), build an argparse CLI so users can configure scans from the terminal, and create a formatted results table that looks professional.

In this step, get ready to:
  • Add forward and reverse DNS resolution for target reconnaissance.
  • Build a flexible CLI with argparse for configurable scans.
  • Create formatted output with a results table and timing summary.
Add forward and reverse DNS resolution

A real scanner needs to handle both hostnames and raw IP addresses as targets. If the user passes a hostname, the scanner resolves it to an IP for socket connections. If they pass an IP, it performs a reverse lookup to display the hostname for context.

  • In portscanner.py, find the line that says HTTP_PORTS = {80, 8080, 8443, 443}.
  • Add the following resolve_target() function below it, leaving a blank line between HTTP_PORTS and the new function:
def resolve_target(target):
    """Resolve target to (ip, hostname) tuple."""
    ip = None
    hostname = None
    try:
        # If this succeeds, target is already an IP address
        socket.inet_aton(target)
        ip = target
        try:
            # Reverse DNS lookup to find the hostname
            hostname = socket.gethostbyaddr(target)[0]
        except socket.herror:
            hostname = None
    except socket.error:
        # Target is a hostname, resolve to IP
        hostname = target
        try:
            ip = socket.gethostbyname(target)
        except socket.gaierror:
            print(f"Error: Could not resolve hostname '{target}'")
            raise SystemExit(1)
    return ip, hostname

What does this code do?

This function figures out whether the user passed an IP address or a hostname, then resolves whichever is missing.

  • socket.inet_aton(target) tries to parse the target as an IP. If it succeeds, the target is an IP and we do a reverse DNS lookup with socket.gethostbyaddr() to get the hostname.
  • If inet_aton raises socket.error, the target must be a hostname. We resolve it to an IP with socket.gethostbyname().
  • If the hostname cannot be resolved, the function prints an error and exits with SystemExit(1).
  • Save portscanner.py.
  • You won't see a change yet. The CLI we build next will call this function.

Seeing a syntax error on save?

  • Make sure the function is placed after the HTTP_PORTS line and before the scan_port() function.
  • Check that the try/except blocks are properly indented with consistent spacing (4 spaces per level).

Help me debug a syntax error in my resolve_target function

Build the port parser and CLI

The CLI needs to accept port specifications in multiple formats. A user might scan a range like 1-1024, a comma-separated list like 22,80,443, or a single port like 80. A helper function will parse all three formats into a list of integers.

  • In portscanner.py, scroll down to the end of the identify_service() function.
  • Add the following parse_ports() function below it:
def parse_ports(port_string):
    """Parse port specification string into a list of ports."""
    ports = []
    for part in port_string.split(","):
        part = part.strip()
        if "-" in part:
            # Handle range format like "1-1024"
            start, end = part.split("-", 1)
            ports.extend(range(int(start), int(end) + 1))
        else:
            # Handle single port like "80"
            ports.append(int(part))
    return ports

What does this code do?

  • The function splits the port string on commas first, handling inputs like 22,80,443.
  • For each part, it checks if a dash is present. If so, it treats it as a range (1-1024 becomes ports 1 through 1024).
  • If no dash is found, it treats the part as a single port number.
  • Save portscanner.py.
  • You won't see a change yet. The main() function in the next chunk will call parse_ports() with the CLI argument.
Create formatted output and tie everything together

Professional scanners present their findings in clean, aligned tables. You will write a print_results() function for the output, then a main() function that uses argparse to wire everything together into a complete CLI tool.

  • In portscanner.py, add the following print_results() function directly below parse_ports():
def print_results(results, ip, hostname, ports_scanned, elapsed):
    """Print formatted scan results."""
    # Header with target info
    print("\n" + "=" * 65)
    print(f"  Scan Report for {ip}")
    if hostname:
        print(f"  Hostname: {hostname}")
    print("=" * 65)
    # Column headers
    print(f"\n  {'PORT':<10}{'STATE':<10}{'SERVICE':<15}{'VERSION'}")
    print(f"  {'-'*8:<10}{'-'*7:<10}{'-'*11:<15}{'-'*25}")
    # One row per open port
    for r in results:
        version_display = r["version"] if r["version"] else ""
        print(f"  {r['port']:<10}{'open':<10}{r['service']:<15}{version_display}")
    # Summary footer
    print(f"\n  {'-' * 60}")
    print(f"  {len(ports_scanned)} ports scanned | {len(results)} open | {elapsed:.2f}s elapsed")
    print("=" * 65 + "\n")

What does this code do?

  • The function prints a bordered header showing the target IP and hostname.
  • It uses f-string width specifiers (:<10) to align the PORT, STATE, SERVICE, and VERSION columns neatly.
  • The footer summarizes the total ports scanned, how many were open, and how long the scan took.
  • Save portscanner.py.
  • You won't see a change yet. The main() function below calls print_results() with the scan data.

Now add the main() function directly below print_results(). This is the orchestration layer that parses CLI arguments, runs the scan, and prints output:

def main():
    parser = argparse.ArgumentParser(
        description="TCP Port Scanner with Service Detection"
    )
    # Positional argument: the target to scan
    parser.add_argument("target", help="Target IP address or hostname")
    # Optional arguments for scan configuration
    parser.add_argument(
        "--ports", default="1-1024", help="Port range (e.g. 1-1024, 22,80,443)"
    )
    parser.add_argument(
        "--threads", type=int, default=100, help="Number of threads (default: 100)"
    )
    parser.add_argument(
        "--timeout", type=float, default=1.0, help="Timeout in seconds (default: 1.0)"
    )
    parser.add_argument(
        "--output", help="Export results to JSON file (e.g. report.json)"
    )
    args = parser.parse_args()

    # Resolve target hostname/IP
    ip, hostname = resolve_target(args.target)
    ports = parse_ports(args.ports)

    # Print scan header
    print(f"\n  Starting scan against {ip}")
    if hostname:
        print(f"  Hostname: {hostname}")
    print(f"  Ports: {args.ports} | Threads: {args.threads} | Timeout: {args.timeout}s")
    print(f"  Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"\n  Scanning...")

    # Run the scan and identify services
    start_time = time.time()
    open_ports = scan_host(ip, ports, args.threads, args.timeout)
    results = []
    for port in open_ports:
        info = identify_service(ip, port, args.timeout)
        results.append(info)
    elapsed = time.time() - start_time

    # Display results
    print_results(results, ip, hostname, ports, elapsed)

    # Optional JSON export (used in the Secret Mission)
    if args.output:
        report = {
            "scan_metadata": {
                "target": args.target,
                "ip": ip,
                "hostname": hostname,
                "ports_scanned": len(ports),
                "threads": args.threads,
                "timeout": args.timeout,
                "start_time": datetime.now().isoformat(),
                "duration_seconds": round(elapsed, 2),
            },
            "results": results,
        }
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2)
        print(f"  Report saved to: {args.output}\n")

What does this code do?

The main() function is the entry point that ties all your helper functions together.

  • argparse defines a CLI interface with a positional target argument and optional --ports, --threads, --timeout, and --output flags.
  • It calls resolve_target() to handle DNS, parse_ports() to build the port list, and scan_host() to find open ports.
  • For each open port, it calls identify_service() to grab banners and extract versions.
  • The --output flag triggers a JSON export. You will use this in the Secret Mission.

Now replace the temporary test block at the bottom of your file. Find these lines:

if __name__ == "__main__":
    open_ports = scan_host("scanme.nmap.org", range(1, 1025), 100, 1.0)
    print("Open ports:", open_ports)
  • Delete those three lines and replace them with:
if __name__ == "__main__":
    main()
  • Save portscanner.py.
  • Test the finished CLI by running this command:
python3 portscanner.py scanme.nmap.org --ports 1-100 --threads 50

You should see a formatted report with a header showing the target IP and hostname, a table of open ports with service names and versions, and a summary line showing the elapsed time.

Seeing a NameError or ImportError?

  • If you see NameError: name 'resolve_target' is not defined, make sure the resolve_target() function is defined above main() in the file.
  • If you see NameError: name 'parse_ports' is not defined, check that parse_ports() appears above main() as well.
  • If the scan hangs, try lowering the timeout with --timeout 0.5.

Help me fix a NameError in my port scanner main function

Why argparse over sys.argv?

You could parse sys.argv manually, but argparse gives you type conversion, default values, and auto-generated --help output for free. Run python3 portscanner.py --help to see the usage message it generates automatically.

✔️ Awesome, I've got everything!

Great. Double check you have saved portscanner.py and your scan output looks clean.

ⓧ I'd like to double check the full code

#!/usr/bin/env python3
"""TCP Port Scanner with Service Detection."""

import socket
import ssl
import concurrent.futures
import re
import argparse
import json
import time
from datetime import datetime


COMMON_SERVICES = {
    21: "FTP",
    22: "SSH",
    25: "SMTP",
    53: "DNS",
    80: "HTTP",
    110: "POP3",
    143: "IMAP",
    443: "HTTPS",
    465: "SMTPS",
    993: "IMAPS",
    995: "POP3S",
    3306: "MySQL",
    5432: "PostgreSQL",
    6379: "Redis",
    8080: "HTTP-Alt",
    8443: "HTTPS-Alt",
}

TLS_PORTS = {443, 8443, 993, 995, 465}

HTTP_PORTS = {80, 8080, 8443, 443}


def resolve_target(target):
    """Resolve target to (ip, hostname) tuple."""
    ip = None
    hostname = None
    try:
        socket.inet_aton(target)
        ip = target
        try:
            hostname = socket.gethostbyaddr(target)[0]
        except socket.herror:
            hostname = None
    except socket.error:
        hostname = target
        try:
            ip = socket.gethostbyname(target)
        except socket.gaierror:
            print(f"Error: Could not resolve hostname '{target}'")
            raise SystemExit(1)
    return ip, hostname


def scan_port(host, port, timeout):
    """Scan a single port. Returns port number if open, None if closed."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    try:
        result = sock.connect_ex((host, port))
        if result == 0:
            return port
        return None
    except socket.error:
        return None
    finally:
        sock.close()


def scan_host(host, ports, max_threads, timeout):
    """Scan multiple ports concurrently. Returns sorted list of open ports."""
    open_ports = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
        future_to_port = {
            executor.submit(scan_port, host, port, timeout): port
            for port in ports
        }
        for future in concurrent.futures.as_completed(future_to_port):
            result = future.result()
            if result is not None:
                open_ports.append(result)
    return sorted(open_ports)


def grab_banner(host, port, timeout):
    """Grab banner from a non-TLS service."""
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect((host, port))
        if port in HTTP_PORTS:
            request = f"HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n"
            sock.sendall(request.encode())
        banner = sock.recv(1024).decode(errors="replace").strip()
        sock.close()
        return banner
    except (socket.timeout, socket.error, OSError):
        return None


def grab_banner_ssl(host, port, timeout):
    """Grab banner from a TLS-wrapped service."""
    try:
        context = ssl.create_default_context()
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect((host, port))
        ssock = context.wrap_socket(sock, server_hostname=host)
        if port in HTTP_PORTS:
            request = f"HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n"
            ssock.sendall(request.encode())
        banner = ssock.recv(1024).decode(errors="replace").strip()
        ssock.close()
        return banner
    except (socket.timeout, socket.error, ssl.SSLError, OSError):
        return None


def extract_version(banner):
    """Extract service version from banner text."""
    if not banner:
        return None
    patterns = [
        r"(OpenSSH[_\s][\d.]+\w*)",
        r"(Apache/[\d.]+)",
        r"(nginx/[\d.]+)",
        r"Server:\s*(.+)",
    ]
    for pattern in patterns:
        match = re.search(pattern, banner)
        if match:
            return match.group(1)
    return None


def identify_service(host, port, timeout):
    """Identify service running on an open port."""
    service_name = COMMON_SERVICES.get(port, "unknown")
    if port in TLS_PORTS:
        banner = grab_banner_ssl(host, port, timeout + 2)
    else:
        banner = grab_banner(host, port, timeout + 2)
    version = extract_version(banner)
    return {
        "port": port,
        "state": "open",
        "service": service_name,
        "banner": banner[:120] if banner else None,
        "version": version,
    }


def parse_ports(port_string):
    """Parse port specification string into a list of ports."""
    ports = []
    for part in port_string.split(","):
        part = part.strip()
        if "-" in part:
            start, end = part.split("-", 1)
            ports.extend(range(int(start), int(end) + 1))
        else:
            ports.append(int(part))
    return ports


def print_results(results, ip, hostname, ports_scanned, elapsed):
    """Print formatted scan results."""
    print("\n" + "=" * 65)
    print(f"  Scan Report for {ip}")
    if hostname:
        print(f"  Hostname: {hostname}")
    print("=" * 65)
    print(f"\n  {'PORT':<10}{'STATE':<10}{'SERVICE':<15}{'VERSION'}")
    print(f"  {'-'*8:<10}{'-'*7:<10}{'-'*11:<15}{'-'*25}")
    for r in results:
        version_display = r["version"] if r["version"] else ""
        print(f"  {r['port']:<10}{'open':<10}{r['service']:<15}{version_display}")
    print(f"\n  {'-' * 60}")
    print(f"  {len(ports_scanned)} ports scanned | {len(results)} open | {elapsed:.2f}s elapsed")
    print("=" * 65 + "\n")


def main():
    parser = argparse.ArgumentParser(
        description="TCP Port Scanner with Service Detection"
    )
    parser.add_argument("target", help="Target IP address or hostname")
    parser.add_argument(
        "--ports", default="1-1024", help="Port range (e.g. 1-1024, 22,80,443)"
    )
    parser.add_argument(
        "--threads", type=int, default=100, help="Number of threads (default: 100)"
    )
    parser.add_argument(
        "--timeout", type=float, default=1.0, help="Timeout in seconds (default: 1.0)"
    )
    parser.add_argument(
        "--output", help="Export results to JSON file (e.g. report.json)"
    )
    args = parser.parse_args()

    ip, hostname = resolve_target(args.target)
    ports = parse_ports(args.ports)

    print(f"\n  Starting scan against {ip}")
    if hostname:
        print(f"  Hostname: {hostname}")
    print(f"  Ports: {args.ports} | Threads: {args.threads} | Timeout: {args.timeout}s")
    print(f"  Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"\n  Scanning...")

    start_time = time.time()
    open_ports = scan_host(ip, ports, args.threads, args.timeout)
    results = []
    for port in open_ports:
        info = identify_service(ip, port, args.timeout)
        results.append(info)
    elapsed = time.time() - start_time

    print_results(results, ip, hostname, ports, elapsed)

    if args.output:
        report = {
            "scan_metadata": {
                "target": args.target,
                "ip": ip,
                "hostname": hostname,
                "ports_scanned": len(ports),
                "threads": args.threads,
                "timeout": args.timeout,
                "start_time": datetime.now().isoformat(),
                "duration_seconds": round(elapsed, 2),
            },
            "results": results,
        }
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2)
        print(f"  Report saved to: {args.output}\n")


if __name__ == "__main__":
    main()

Your port scanner is now a fully functional CLI tool. Next up, you will extend it with a JSON export feature in the Secret Mission.

Secret mission

Export a JSON Scan Report

Your scanner prints results to the terminal, but what if you want to save them for later analysis or feed them into another tool? Add a --output flag that exports the full scan results to a structured JSON file with timestamps, target metadata, and all findings.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Python's standard library, so there are no ongoing costs or cloud resources to worry about.

Decide whether to keep your project files for future use or delete them entirely.

Resources you used:

  • The port-scanner directory on your Desktop (contains portscanner.py and report.json).

Keep everything running

No action needed. Choose this if you want to keep experimenting with your port scanner or extend it further.

  • Your port-scanner directory stays on your Desktop ready to use anytime.
  • Run scans whenever you want with python3 portscanner.py.

Pause - I'll come back to this later

Since this project is local-only with no running processes, there is nothing to pause. Your files sit idle on disk until you use them again.

  • No background processes are running. The scanner only runs when you invoke it manually.
  • Your files will be waiting in ~/Desktop/port-scanner whenever you come back.

Delete - I don't want to use this again

Remove all project files from your machine.

  • Delete the project directory by running this command in Terminal:
rm -rf ~/Desktop/port-scanner
  • Confirm the folder is gone by running:
ls ~/Desktop | grep port-scanner

You should see no output, confirming the directory has been removed.

Nice Work!

Nice Work!

You just built a fully functional TCP port scanner with service detection using nothing but Python's standard library. This is the same core technique that professional tools like Nmap use to map networks and identify running services.

You've learned how to:

  • Build a multi-threaded TCP port scanner using Python's socket module and ThreadPoolExecutor to detect open ports via connect_ex() at 100+ ports per second.
  • Create a service detection engine using banner grabbing with protocol-aware probes and SSL/TLS wrapping to fingerprint services behind encrypted connections.
  • Build a polished CLI tool with forward and reverse DNS resolution, configurable options via argparse, and formatted results tables with timing stats.
  • Secret Mission: Added JSON report export with the --output flag for structured, machine-readable scan results.

Ready to quiz yourself?