Build a Stealth Port Scanner in Python

Build a multi-threaded TCP port scanner with banner grabbing and recon reports.

Introduction

30 Second Summary

Every device connected to a network exposes numbered doors called ports. Security professionals probe these ports to discover what services are running and whether they're vulnerable. This process is called reconnaissance, and it's the first phase of any penetration test.

In this project, you will build a command-line port scanner in Python that discovers open TCP ports on a target host, identifies running services by grabbing their banners, and generates a professional reconnaissance report.

What You'll Build

You will run your finished scanner against scanme.nmap.org (a legal target provided by the Nmap project for testing) and watch it discover live services like SSH and HTTP in seconds, with results highlighted in color and saved to a timestamped report file.

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

  • A multi-threaded scanner that discovers open ports in seconds instead of minutes, with results color-coded green so you can instantly spot live services on any target.
  • Service identification through banner grabbing that reveals exactly what software version is running behind each open port (SSH version strings, HTTP server headers, and more).
  • A complete CLI tool with stealth timing, configurable scan speed, and an automated report generator that saves your findings to a timestamped file ready for your portfolio.
  • Secret Mission: Add a vulnerability lookup feature that cross-references discovered service banners against known CVEs.

Are there any prerequisites?

You need basic Python knowledge (variables, loops, functions). You should also have Python 3.12 and VS Code already installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your security career.

Set Up Your Project

Your port scanner will rely on Python's standard library plus one external package for colored output. Before writing any scanning code, you need a clean project folder and an isolated virtual environment to keep your dependencies separate from the rest of your system.

In this step, you'll create the project structure, install colorama for colored terminal output, and confirm everything works with a quick test script.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install colorama for colored terminal output.
  • Verify the setup with a test script that prints colored text.
Create your project folder and install colorama
  • Press the Windows key to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (macOS: Cmd+`).
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir stealth-scanner
cd stealth-scanner
  • Confirm the folder was created by running:
ls

You should see an empty listing (no files yet). The folder exists and you're inside it.

  • Now open this folder in VS Code by running:
code .

VS Code reopens with stealth-scanner as the workspace folder. You should see the folder name in the Explorer sidebar on the left.

Trust this folder?

VS Code may show a dialog asking if you trust the authors of this folder. Click Yes, I trust the authors to continue.

  • Open a new terminal in VS Code by pressing Ctrl+`.
  • Create a virtual environment and activate it by running these commands:
python -m venv venv
venv\Scripts\activate

Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.

Seeing a script execution error?

Windows PowerShell may block the activation script by default. If you see an error about running scripts being disabled, run this command first:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Then try venv\Scripts\activate again. Help me fix my virtual environment activation.

  • Install colorama by running:
pip install colorama==0.4.6

You should see output ending with Successfully installed colorama-0.4.6.

What is colorama?

Colorama makes ANSI color codes work on Windows terminals. Without it, your scanner output would show raw escape characters instead of colored text.

pip not recognized?

Make sure you see (venv) in your terminal prompt. If it's missing, the virtual environment isn't active. Run venv\Scripts\activate first, then try the install again. Help me fix pip in my virtual environment.

Create your test script and verify the setup

Now that colorama is installed, you'll create the main scanner file and confirm that colored output works.

  • In the VS Code Explorer sidebar, click the New File icon (a page with a plus sign) next to the folder name.
  • Name the file scanner.py and press Enter.
  • Paste the following code into your new file:
from colorama import just_fix_windows_console, Fore, Style

# Enable ANSI color support on Windows
just_fix_windows_console()

# Print a colored message to confirm everything works
print(f"{Fore.GREEN}Setup complete!{Style.RESET_ALL}")

What does this code do?

  • just_fix_windows_console() patches the Windows terminal so it understands ANSI color codes.
  • Fore.GREEN sets the text color to green. Style.RESET_ALL resets formatting back to normal after the colored text.
  • Save the file by pressing Ctrl+S.

Before you run this, what color do you expect the output text to be?

  • Run the script by entering this command in your terminal:
python scanner.py

You should see Setup complete! printed in green text. This confirms Python, your virtual environment, and colorama are all working correctly.

Not seeing green text?

  • If you see a ModuleNotFoundError, your virtual environment may not be active. Check for (venv) in your prompt, then run pip install colorama==0.4.6 again.
  • If you see raw escape characters like \033[32m instead of color, make sure just_fix_windows_console() is on its own line before the print statement.

Help me debug my colorama setup.

✔️ Awesome, I've got everything!

Great. Make sure you've saved scanner.py and your green message is showing in the terminal.

ⓧ I'd like to double check the full code

Here is the complete scanner.py at this point:

from colorama import just_fix_windows_console, Fore, Style

just_fix_windows_console()

print(f"{Fore.GREEN}Setup complete!{Style.RESET_ALL}")

Your environment is locked and loaded. Next up, you'll write the core scanning logic and point it at a live target to discover open ports.

Scan Your First Ports

Your project environment is ready. Now it's time to prove the core concept behind every port scanner: can you reach a remote target and detect which services are listening?

Before building a full tool, you need to confirm one thing works. A single TCP connection attempt to a known-open port proves your scanner can reach a target and detect open services. In this step, you'll resolve a hostname to an IP address and attempt connections to three specific ports.

In this step, get ready to:
  • Resolve a hostname to an IP address using Python's socket module.
  • Attempt TCP connections to ports 22, 80, and 443.
  • Display colored OPEN/CLOSED labels for each port result.
Resolve the target hostname

Before connecting to any ports, your scanner needs to know the IP address of the target. DNS resolution translates a human-readable hostname into a numeric IP address that your code can connect to.

You'll scan scanme.nmap.org, a server explicitly provided by the Nmap project for testing purposes.

  • Replace the entire contents of scanner.py with the following code:
import socket
from colorama import just_fix_windows_console, Fore, Style

just_fix_windows_console()

# Target hostname to scan
target = "scanme.nmap.org"
# Resolve hostname to IP address
ip = socket.gethostbyname(target)
print(f"Resolved {target} to {ip}")

What does this code do?

  • import socket brings in Python's built-in networking module, which handles all TCP connections.
  • socket.gethostbyname(target) performs DNS resolution, converting the hostname into a numeric IP address your code can connect to.
  • The print statement confirms the resolution worked before you attempt any connections.

Why scanme.nmap.org?

The Nmap project provides this server specifically for people to practice scanning. Their documentation states: "For testing purposes, you have permission to scan the host scanme.nmap.org." This avoids any legal concerns while you learn.

Do not scan hosts you don't own or have explicit permission to test.

  • Save scanner.py.
  • Run the script in your terminal:
python scanner.py

You should see output like Resolved scanme.nmap.org to 45.33.32.156 (the exact IP may differ).

Seeing a 'could not resolve hostname' error?

  • Check your internet connection. DNS resolution requires network access.
  • Make sure you spelled scanme.nmap.org exactly as shown, with no extra spaces.
  • help me debug a hostname resolution error in my Python port scanner
Add port scanning with TCP connections

A TCP connect scan works by attempting a full connection to each port. If the connection succeeds, the port is open and a service is listening. If it's refused or times out, the port is closed or filtered.

  • In scanner.py, find this line:
print(f"Resolved {target} to {ip}")
  • Add the following code below it:
for port in [22, 80, 443]:
    # Create a new TCP socket for each port
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(1.0)
    # Attempt connection (returns 0 if successful)
    result = s.connect_ex((ip, port))
    s.close()
    if result == 0:
        print(f"{Fore.GREEN}Port {port}: OPEN{Style.RESET_ALL}")
    else:
        print(f"{Fore.RED}Port {port}: CLOSED{Style.RESET_ALL}")

What does this code do?

  • The loop tries three well-known ports: 22 (SSH), 80 (HTTP), and 443 (HTTPS).
  • socket.connect_ex() attempts a TCP connection and returns 0 on success (port open) or an error code on failure (port closed or filtered).
  • settimeout(1.0) limits each attempt to 1 second. If a firewall silently drops the packet, the code won't hang forever.
  • Each open port prints in green. Each closed port prints in red.
  • Save scanner.py.

Before you run this, think about what you expect to see. Which of the three ports (22, 80, 443) do you think will be open on a server that hosts the Nmap website?

  • Run the scanner:
python scanner.py

You should see Port 22 and Port 80 displayed as OPEN in green text. Port 443 may show as CLOSED in red.

What do these results mean?

Port 22 is open because the server runs an SSH service. Port 80 is open because it serves a web page. Port 443 may be closed because the server doesn't serve HTTPS on that port.

Your scanner just performed the exact same TCP connect scan technique that professional tools like Nmap use under the hood. The difference is scale and speed.

All ports showing CLOSED?

  • Check that your firewall or VPN isn't blocking outbound connections on ports 22 and 80.
  • Try increasing the timeout from 1.0 to 2.0 in case your network is slow.
  • Confirm you can reach the target by opening http://scanme.nmap.org in your browser. If the page loads, port 80 is reachable.
  • help me troubleshoot why my port scanner shows all ports as closed

✔️ Awesome, I've got everything!

Great. Double check you've saved scanner.py and move on to the next step.

ⓧ I'd like to double check the full code

Here's the complete scanner.py at this point:

import socket
from colorama import just_fix_windows_console, Fore, Style

just_fix_windows_console()

target = "scanme.nmap.org"
ip = socket.gethostbyname(target)
print(f"Resolved {target} to {ip}")

for port in [22, 80, 443]:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(1.0)
    result = s.connect_ex((ip, port))
    s.close()
    if result == 0:
        print(f"{Fore.GREEN}Port {port}: OPEN{Style.RESET_ALL}")
    else:
        print(f"{Fore.RED}Port {port}: CLOSED{Style.RESET_ALL}")

You just proved the core concept works. Your scanner can reach a remote host and detect live services in seconds. Next up, you'll expand from three ports to a full range and discover why speed becomes a problem.

Scan a Range of Ports

In the previous step, you proved your scanner can detect open ports on a remote host. But checking only three ports barely scratches the surface.

A real attack surface has thousands of potential entry points. To map it properly, you need to scan a range of ports and measure how long the scan takes. This reveals a critical performance problem with the current sequential approach.

In this step, get ready to:
  • Expand the scanner to loop through ports 1-100.
  • Add a timer to measure total scan duration.
  • Experience the performance wall of sequential scanning firsthand.
Expand the scan to 100 ports and add a timer

Right now your scanner checks a hardcoded list of three ports. You need two changes: scan a full range (ports 1-100), and wrap the scan in a timer so you can measure exactly how long it takes.

  • In scanner.py, find the import line at the top of the file:
import socket
from colorama import just_fix_windows_console, Fore, Style
  • Add from datetime import datetime between the two existing imports, so your imports look like this:
import socket
from datetime import datetime
from colorama import just_fix_windows_console, Fore, Style
  • Find the print(f"Resolved {target} to {ip}") line. Add a blank line below it, then add this line to start the timer:
start_time = datetime.now()
  • Find the for loop line:
for port in [22, 80, 443]:
  • Replace it with range(1, 101) to scan ports 1 through 100:
for port in range(1, 101):
  • After the for loop's else block (the last line inside the loop), add these lines at the bottom of the file to stop the timer and print the duration:
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
print(f"\nScan completed in {duration:.2f} seconds")

What do these changes do?

  • datetime.now() captures the current time before and after the scan loop.
  • range(1, 101) generates integers from 1 to 100, so the loop attempts a TCP connection to every port in that range.
  • total_seconds() converts the time difference into a readable number of seconds.
  • Save scanner.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it.

ⓧ I'd like to double check the full code

Here is what your complete scanner.py should look like now:

import socket
from datetime import datetime
from colorama import just_fix_windows_console, Fore, Style

just_fix_windows_console()

target = "scanme.nmap.org"
ip = socket.gethostbyname(target)
print(f"Resolved {target} to {ip}")

start_time = datetime.now()

for port in range(1, 101):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(1.0)
    result = s.connect_ex((ip, port))
    s.close()
    if result == 0:
        print(f"{Fore.GREEN}Port {port}: OPEN{Style.RESET_ALL}")
    else:
        print(f"{Fore.RED}Port {port}: CLOSED{Style.RESET_ALL}")

end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
print(f"\nScan completed in {duration:.2f} seconds")
Run the scan and observe the performance wall

Before you run this, think: you have 100 ports to check, each with a 1-second timeout. How long do you expect this scan to take?

  • Run the scanner by entering this command in your terminal:
python scanner.py

Watch the output scroll. Ports 22 and 80 appear as OPEN in green almost immediately. Then the scanner crawls through the remaining ports one by one.

After 60-90 seconds, the scan finally completes and prints its total duration. That wait is the performance wall you just hit.

Why does it take so long?

Most ports on scanme.nmap.org are filtered. A firewall silently drops your connection attempts without sending any response. Your scanner waits the full 1-second timeout for each filtered port before moving on to the next one.

With ~95 filtered ports each burning a full second, the math is simple: 95 seconds of dead waiting. This is unusable for real reconnaissance work where you need to scan thousands of ports.

Scan hanging or no output at all?

  • Check your internet connection. The scanner needs to reach scanme.nmap.org over the network.
  • If you see a DNS resolution error, your network may be blocking outbound connections. Try a different network or check your firewall settings.
  • If the scan seems frozen, that is normal. Filtered ports produce no output while the timeout runs. Wait for the full scan to complete.

Still stuck? Help me debug why my port scanner isn't producing output.

You just felt the pain of sequential scanning firsthand. Every filtered port wastes a full second of dead waiting. Next up, you will solve this with multi-threading, checking dozens of ports simultaneously and collapsing that 60-90 second scan down to just a few seconds.

Add Multi-Threading and Banner Grabbing

In the last step, you experienced the core problem with sequential scanning: checking 100 ports took over a minute because each filtered port waited the full timeout before moving to the next.

Professional scanners solve this with concurrency. Instead of checking one port at a time, you launch many connection attempts simultaneously. Python's ThreadPoolExecutor does exactly this. This step also adds banner grabbing to identify what software is running on each open port.

In this step, get ready to:
  • Refactor your scanner to use multi-threading with 100 concurrent workers.
  • Add a banner grabbing function that identifies services on open ports.
  • See the same scan complete in seconds instead of minutes.
Replace your scanner with a threaded version

The sequential for-loop needs to be completely replaced. You will rewrite scanner.py with a new structure that wraps port scanning into a function, adds a port-to-service dictionary, and dispatches all 100 scans at once using a thread pool.

  • Select all the code in scanner.py and delete it.
  • Replace it with the following imports and port dictionary:
import socket
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
from colorama import just_fix_windows_console, Fore, Style
just_fix_windows_console()

COMMON_PORTS = {
    21: "FTP",
    22: "SSH",
    23: "Telnet",
    25: "SMTP",
    53: "DNS",
    80: "HTTP",
    110: "POP3",
    135: "MSRPC",
    139: "NetBIOS",
    143: "IMAP",
    443: "HTTPS",
    445: "SMB",
    993: "IMAPS",
    995: "POP3S",
    1433: "MSSQL",
    1723: "PPTP",
    3306: "MySQL",
    3389: "RDP",
    5432: "PostgreSQL",
    5900: "VNC",
    8080: "HTTP-Proxy",
    8443: "HTTPS-Alt",
}

What does this code do?

The new import from concurrent.futures import ThreadPoolExecutor, as_completed brings in Python's built-in thread pool. ThreadPoolExecutor manages a pool of worker threads. as_completed yields results as each thread finishes, so you see open ports immediately rather than waiting for all threads to complete.

The COMMON_PORTS dictionary maps well-known port numbers to their service names. This lets your scanner display human-readable labels like "SSH" or "HTTP" instead of just raw port numbers.

  • Below the COMMON_PORTS dictionary, add the scan_port function:
def scan_port(ip, port, timeout):
    """Scan a single port and return the result."""
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(timeout)
        result = s.connect_ex((ip, port))
        s.close()
        if result == 0:
            return port, "open"
        else:
            return port, "closed"
    except socket.timeout:
        return port, "filtered"
    except Exception:
        return port, "error"

What does scan_port do?

This function wraps the same connect_ex() logic from your earlier loop into a self-contained unit. Each thread in the pool calls this function independently. It returns a tuple of the port number and its status so the main thread knows what happened.

The socket.timeout exception catches ports that don't respond at all (filtered by a firewall). These are the ports that made your sequential scan so slow.

  • Below the scan_port function, add the main execution code that dispatches all scans to the thread pool:
target = "scanme.nmap.org"
ip = socket.gethostbyname(target)
print(f"Resolved {target} to {ip}")

start_time = datetime.now()

with ThreadPoolExecutor(max_workers=100) as executor:
    futures = {
        executor.submit(scan_port, ip, port, 1.0): port
        for port in range(1, 101)
    }
    for future in as_completed(futures):
        port, status = future.result()
        if status == "open":
            service = COMMON_PORTS.get(port, "Unknown")
            print(
                f"  {Fore.GREEN}[OPEN]{Style.RESET_ALL} "
                f"Port {port} {Fore.CYAN}{service}{Style.RESET_ALL}"
            )

end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
print(f"\nScan completed in {duration:.2f} seconds")

How does the thread pool work?

The with ThreadPoolExecutor(max_workers=100) creates 100 worker threads. The dictionary comprehension submits all 100 port scans immediately. Instead of waiting for port 1 before checking port 2, all ports are checked simultaneously.

The as_completed(futures) loop yields each result the instant its thread finishes. Open ports appear on screen immediately while filtered ports are still timing out in the background.

Why 100 workers?

Windows limits a process to approximately 511 concurrent sockets. Using 100 workers stays well under this limit while still scanning all ports in parallel. Do not increase beyond 200 on Windows.

Before you run this, predict: how fast do you think 100 ports will scan now that all connections happen at the same time?

  • Save scanner.py and run it:
python scanner.py

The scan completes in 1-2 seconds. Your open ports appear almost instantly while the filtered ports time out in the background. That is the power of multi-threading.

Still taking a long time?

Make sure you replaced ALL the old code. If the sequential for-loop is still in the file, it will run alongside the threaded code. Delete everything and paste only the three sections above.

If you see an error about ThreadPoolExecutor not being defined, check that your import line reads from concurrent.futures import ThreadPoolExecutor, as_completed exactly.

help me debug why my threaded port scanner is still running slowly

Add banner grabbing to identify services

Knowing a port is open is only the first step. A real reconnaissance tool identifies what software is running behind that port. This is called banner grabbing. Services like SSH and HTTP often announce their version when you connect.

  • In scanner.py, add the grab_banner function between the COMMON_PORTS dictionary and the scan_port function:
def grab_banner(ip, port, timeout):
    """Attempt to grab the service banner from an open port."""
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(timeout)
        s.connect((ip, port))
        if port in (80, 8080, 443, 8443):
            s.send(b"HEAD / HTTP/1.1\r\nHost: " + ip.encode() + b"\r\n\r\n")
        banner = s.recv(1024).decode(errors="ignore").strip()
        s.close()
        return banner[:100]
    except Exception:
        return ""

What does grab_banner do?

This function opens a fresh connection to an open port and reads whatever the service sends back. Many services (like SSH) immediately announce their version string when a client connects.

Web servers need a prompt first. The if port in (80, 8080, 443, 8443) check sends an HTTP HEAD request to trigger a response. The banner[:100] truncates long responses to keep the output readable.

  • Now find the for future in as_completed(futures): loop in the main section at the bottom of your file. Find this block:
    for future in as_completed(futures):
        port, status = future.result()
        if status == "open":
            service = COMMON_PORTS.get(port, "Unknown")
            print(
                f"  {Fore.GREEN}[OPEN]{Style.RESET_ALL} "
                f"Port {port} {Fore.CYAN}{service}{Style.RESET_ALL}"
            )
  • Replace that block with this version that calls grab_banner and displays the result:
    for future in as_completed(futures):
        port, status = future.result()
        if status == "open":
            banner = grab_banner(ip, port, 1.0)
            service = COMMON_PORTS.get(port, "Unknown")
            print(
                f"  {Fore.GREEN}[OPEN]{Style.RESET_ALL} Port {port:<6} "
                f"{Fore.CYAN}{service:<12}{Style.RESET_ALL} "
                f"{Fore.MAGENTA}{banner}{Style.RESET_ALL}"
            )

What changed?

The new version calls grab_banner(ip, port, 1.0) for every open port and displays the result in magenta. The :<6 and :<12 format specifiers pad the port number and service name so the output lines up in neat columns.

Run the scanner and observe your results

Before you run this, what do you think you will see on ports 22 and 80? Think about what software typically runs on those ports.

  • Save scanner.py and run it:
python scanner.py

The scan completes in under 3 seconds. Open ports now display their service banners. Port 22 shows an OpenSSH version string. Port 80 shows the HTTP server header.

Not seeing any banners?

Make sure grab_banner is defined ABOVE scan_port in the file. Python reads top to bottom, so the function must exist before anything calls it.

If banners are empty, the target may be rate-limiting your connections. Wait 30 seconds and try again. Banners from SSH (port 22) are the most reliable.

help me figure out why my banner grabbing is returning empty strings

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and that your scan shows open ports with banners in under 3 seconds.

ⓧ I'd like to double check the full code

Here is the complete scanner.py at this point. Compare your file against it section by section:

  • Imports (4 lines including concurrent.futures).
  • The just_fix_windows_console() call.
  • The COMMON_PORTS dictionary with 22 entries.
  • The grab_banner function.
  • The scan_port function.
  • The main execution block with ThreadPoolExecutor.

Scroll up and confirm each section is present in your file, in that order.

Your scanner now finds open ports in seconds and identifies the software running behind them. Next up, you will add a proper command-line interface, stealth timing to avoid detection, and a report generator to produce a portfolio-ready output file.

Add CLI, Stealth Timing, and Report Generation

Your scanner finds open ports and grabs banners in seconds. But right now, the target and port range are hardcoded.

A portfolio-ready tool needs a proper command-line interface so anyone can run it against any target. It also needs stealth controls to avoid triggering intrusion detection systems, and automated report generation to document your findings.

In this step, get ready to:
  • Add argparse for command-line arguments like target, port range, and stealth delay.
  • Add stealth timing with random jitter to reduce detection.
  • Generate a timestamped reconnaissance report file.
Add command-line arguments and helper functions

Professional tools like Nmap accept command-line arguments so operators can configure scans without editing source code. You will restructure your script from a hardcoded script into a function-based CLI tool.

  • In scanner.py, replace the imports at the top of the file with these updated imports:
import socket
import argparse
import sys
import time
import random
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
from colorama import just_fix_windows_console, Fore, Style

What are the new imports?

  • argparse handles command-line argument parsing.
  • sys lets you exit gracefully on errors.
  • time and random power the stealth delay with jitter.
  • Keep just_fix_windows_console() and the COMMON_PORTS dictionary exactly as they are.
  • Keep grab_banner() exactly as it is.
  • Find the scan_port() function and replace it with this updated version that accepts a stealth delay:
def scan_port(ip, port, timeout, stealth_delay):
    """Scan a single port and return the result."""
    if stealth_delay > 0:
        jitter = random.uniform(0, stealth_delay * 0.5)
        time.sleep(stealth_delay + jitter)
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(timeout)
        result = s.connect_ex((ip, port))
        s.close()
        if result == 0:
            return port, "open"
        else:
            return port, "closed"
    except socket.timeout:
        return port, "filtered"
    except Exception:
        return port, "error"

How does stealth timing work?

When stealth_delay is greater than 0, each thread pauses before probing. The random jitter (0 to 50% of the delay) makes the scan pattern look irregular instead of machine-like. Intrusion detection systems flag evenly-spaced connection attempts, so jitter helps avoid triggering alerts.

  • Below scan_port(), add these three helper functions:
def resolve_target(target):
    """Resolve a hostname to an IP address."""
    try:
        ip = socket.gethostbyname(target)
        return ip
    except socket.gaierror:
        print(f"{Fore.RED}[!] Could not resolve hostname: {target}{Style.RESET_ALL}")
        sys.exit(1)


def parse_ports(port_string):
    """Parse port specification like '22,80,443' or '1-1024'."""
    ports = []
    for part in port_string.split(","):
        if "-" in part:
            start, end = part.split("-", 1)
            ports.extend(range(int(start), int(end) + 1))
        else:
            ports.append(int(part))
    return sorted(set(ports))

What do these helpers do?

  • resolve_target() converts a hostname to an IP and exits cleanly if DNS fails.
  • parse_ports() handles flexible port specs like 22,80,443 or 1-1024. It splits on commas, expands ranges, and deduplicates.
  • Below parse_ports(), add the banner display function:
def print_banner():
    """Print the scanner banner."""
    print(f"{Fore.CYAN}")
    print("=" * 55)
    print("       STEALTH PORT SCANNER v1.0")
    print("       Reconnaissance & Enumeration Tool")
    print("=" * 55)
    print(f"{Style.RESET_ALL}")
  • Now delete all the old inline code at the bottom of the file. Find and remove everything from the line target = "scanme.nmap.org" down to the last line print(f"\nScan completed in {duration:.2f} seconds").
  • In place of the deleted code, add this main() function with the argparse setup:
def main():
    parser = argparse.ArgumentParser(
        description="Stealth Port Scanner - TCP Connect Scan"
    )
    parser.add_argument("target", help="Target hostname or IP address")
    parser.add_argument(
        "-p", "--ports", default="1-1024",
        help="Ports to scan (e.g., 22,80,443 or 1-1024)"
    )
    parser.add_argument(
        "-t", "--timeout", type=float, default=1.0,
        help="Connection timeout in seconds"
    )
    parser.add_argument(
        "-w", "--workers", type=int, default=100,
        help="Number of concurrent threads"
    )
    parser.add_argument(
        "-d", "--delay", type=float, default=0.0,
        help="Stealth delay between probes in seconds"
    )
    parser.add_argument(
        "-o", "--output", default="",
        help="Output report filename"
    )
    args = parser.parse_args()

What do these arguments control?

  • target (positional) is the hostname or IP to scan.
  • -p specifies which ports to scan. Defaults to 1-1024.
  • -t sets the connection timeout per port.
  • -w controls the number of concurrent threads.
  • -d adds a stealth delay between probes.
  • -o lets you name the output report file.
  • Directly below the args = parser.parse_args() line (still inside main()), add a placeholder pass statement and the __name__ guard at the very bottom of the file:
    # More logic coming next
    pass


if __name__ == "__main__":
    main()
  • Save scanner.py.
  • Test that argparse is wired up correctly by running:
python scanner.py --help

You should see a help message listing all six arguments: target, -p, -t, -w, -d, and -o.

Seeing a syntax error?

  • Make sure every parser.add_argument() call has matching parentheses. Count the opening and closing parens carefully.
  • Confirm you deleted ALL of the old inline code. If any of the old target = "scanme.nmap.org" lines remain, they will conflict with the new function structure.
  • Check that def main(): is not indented inside another function.

Help me fix a syntax error in my argparse setup for scanner.py.

Add report generation and the scanning logic

With the CLI structure in place, you need the scanning engine and a report generator. The report captures everything a security professional documents after reconnaissance: target info, timing, and a table of discovered services.

  • In scanner.py, add the generate_report() function below print_banner() and above def main()::
def generate_report(target, ip, results, start_time, end_time, filename):
    """Generate a timestamped reconnaissance report."""
    duration = end_time - start_time
    open_ports = [(port, banner) for port, status, banner in results if status == "open"]
    filtered_ports = [port for port, status, banner in results if status == "filtered"]

    lines = []
    lines.append("=" * 55)
    lines.append("RECONNAISSANCE REPORT")
    lines.append("=" * 55)
    lines.append(f"Target: {target} ({ip})")
    lines.append(f"Scan Date: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
    lines.append(f"Duration: {duration.total_seconds():.2f} seconds")
    lines.append(f"Ports Scanned: {len(results)}")
    lines.append(f"Open Ports: {len(open_ports)}")
    lines.append(f"Filtered Ports: {len(filtered_ports)}")
    lines.append("")
    lines.append("-" * 55)
    lines.append(f"{'PORT':<10}{'SERVICE':<15}{'BANNER'}")
    lines.append("-" * 55)

What does the report header contain?

The report opens with target info, the scan timestamp, duration, and a summary of how many ports were scanned vs. open vs. filtered. This mirrors the format of professional penetration testing reports.

  • Directly below the last line you just added, still inside generate_report(), add the port table and file-writing logic:
    for port, banner in open_ports:
        service = COMMON_PORTS.get(port, "Unknown")
        lines.append(f"{port:<10}{service:<15}{banner}")

    lines.append("")
    lines.append("=" * 55)
    lines.append("END OF REPORT")
    lines.append("=" * 55)

    report_content = "\n".join(lines)

    with open(filename, "w") as f:
        f.write(report_content)

    return report_content

What does this code do?

  • The loop writes one row per open port with its service name and banner text.
  • All lines are joined with newlines and written to the specified filename.
  • The function returns the report content so main() can confirm it was saved.
  • Now go back to main(). Remove the pass statement and the comment above it. Replace them with the scan info display (still inside main(), after args = parser.parse_args()):
    print_banner()

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

    print(f"{Fore.YELLOW}[*] Target: {args.target} ({ip}){Style.RESET_ALL}")
    print(f"{Fore.YELLOW}[*] Ports: {len(ports)} ports to scan{Style.RESET_ALL}")
    print(f"{Fore.YELLOW}[*] Threads: {args.workers}{Style.RESET_ALL}")
    if args.delay > 0:
        print(f"{Fore.YELLOW}[*] Stealth delay: {args.delay}s between probes{Style.RESET_ALL}")
    print(
        f"{Fore.YELLOW}[*] Scan started at: "
        f"{datetime.now().strftime('%H:%M:%S')}{Style.RESET_ALL}"
    )
    print("")
  • Directly below print(""), still inside main(), add the multi-threaded scanning loop:
    start_time = datetime.now()
    results = []
    open_count = 0

    with ThreadPoolExecutor(max_workers=args.workers) as executor:
        futures = {
            executor.submit(scan_port, ip, port, args.timeout, args.delay): port
            for port in ports
        }
        for future in as_completed(futures):
            port, status = future.result()
            if status == "open":
                banner = grab_banner(ip, port, args.timeout)
                service = COMMON_PORTS.get(port, "Unknown")
                results.append((port, status, banner))
                open_count += 1
                print(
                    f"  {Fore.GREEN}[OPEN]{Style.RESET_ALL} Port {port:<6} "
                    f"{Fore.CYAN}{service:<12}{Style.RESET_ALL} "
                    f"{Fore.MAGENTA}{banner}{Style.RESET_ALL}"
                )
            else:
                results.append((port, status, ""))

What changed in the scanning loop?

  • scan_port now receives args.timeout and args.delay from the CLI arguments instead of hardcoded values.
  • Every result (open or closed) is appended to a results list so the report generator has access to all scan data.
  • open_count tracks how many ports were found open for the summary output.
  • Finally, below the scanning loop (still inside main()), add the summary output and report generation:
    end_time = datetime.now()
    duration = (end_time - start_time).total_seconds()

    print("")
    print(f"{Fore.YELLOW}[*] Scan complete in {duration:.2f} seconds{Style.RESET_ALL}")
    print(f"{Fore.GREEN}[*] {open_count} open port(s) found{Style.RESET_ALL}")

    if not args.output:
        timestamp = start_time.strftime("%Y%m%d_%H%M%S")
        filename = f"recon_report_{timestamp}.txt"
    else:
        filename = args.output

    report = generate_report(args.target, ip, results, start_time, end_time, filename)
    print(f"{Fore.CYAN}[*] Report saved to: {filename}{Style.RESET_ALL}")

How does the report filename work?

If the user provides -o with a custom filename, it uses that. Otherwise it auto-generates a timestamped name like recon_report_20260806_143022.txt so each scan produces a unique file.

  • Make sure the if __name__ == "__main__": block is still at the very bottom of the file, below main().
  • Save scanner.py.

Getting an indentation error?

  • Every line inside main() must be indented by 4 spaces. The with ThreadPoolExecutor block needs an additional 4 spaces for its body (8 total), and the for future loop inside that needs 12.
  • Make sure if __name__ == "__main__": is at column 0, not indented inside main().

Help me fix indentation errors in my scanner.py main() function.

Run a full reconnaissance scan

Your scanner is now a complete CLI tool. Before you run it, think about what you expect to happen.

You are about to scan 1024 ports with 100 threads and a 0.1 second stealth delay. Do you think this scan will be faster or slower than your previous 100-port scan without any delay?

  • Run the full scan by running:
python scanner.py scanme.nmap.org -p 1-1024 -d 0.1 -o recon_report.txt

The scan takes longer than the no-delay version because each thread pauses before probing. You should see the cyan banner header, yellow scan parameters, and green OPEN results for ports 22 and 80 with their banners.

  • Confirm the report file was generated by running:
dir recon_report.txt

You should see recon_report.txt listed in the output.

  • View the report contents by running:
type recon_report.txt

You should see a formatted report with the target, scan date, duration, and a table showing each open port with its service name and banner.

What if I see different ports open?

scanme.nmap.org is a live server maintained by the Nmap project. Its open ports can change over time. Port 22 (SSH) and port 80 (HTTP) are almost always open. Other ports may appear or disappear depending on what services are running.

Scan hanging or timing out?

  • If the scan seems stuck, your network may be blocking outbound connections. Try running without stealth: python scanner.py scanme.nmap.org -p 22,80,443 to test with just three ports.
  • Some corporate or university networks block port scanning. Try from a personal network.
  • If you see a DNS resolution error, check your internet connection.

Help me troubleshoot my port scanner timing out when scanning scanme.nmap.org.

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.py and that your report file exists in your project folder.

ⓧ I'd like to double check the full code

Your scanner.py is now over 150 lines. Scroll through and confirm these sections are present, in order:

  • Imports (socket, argparse, sys, time, random, datetime, concurrent.futures, colorama).
  • just_fix_windows_console() call.
  • COMMON_PORTS dictionary (22 entries).
  • grab_banner() function.
  • scan_port() function with stealth_delay parameter and jitter logic.
  • resolve_target() function.
  • parse_ports() function.
  • print_banner() function.
  • generate_report() function.
  • main() function with argparse, scanning loop, and report logic.
  • if __name__ == "__main__": main() at the very bottom.

Your script has gone from a hardcoded proof-of-concept to a professional CLI tool. Next up, you will take on the Secret Mission and add vulnerability lookup to cross-reference discovered services against known CVEs.

Secret mission

Add Vulnerability Lookup

Your scanner finds open ports and grabs banners. But a real red team operator wants to know: are any of those services running versions with known vulnerabilities? Add a CVE lookup feature that cross-references discovered banners against a local vulnerability database and flags dangerous services in your report.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. There are no cloud resources, subscriptions, or ongoing costs to worry about.

Decide whether to keep your project files to continue developing your scanner, or delete them entirely.

Resources you used:

  • Project folder on your Desktop containing scanner.py and your virtual environment.
  • Generated reconnaissance report files (recon_report_*.txt) inside the project folder.
  • The colorama package installed inside your virtual environment.

Keep everything running

No action needed. Choose this if you plan to keep developing your scanner or want to use it for future reconnaissance practice.

  • Your project folder, virtual environment, and all generated reports stay on your Desktop.
  • Consider pushing your project to GitHub as a portfolio piece. Add a README describing what the tool does, the concepts it demonstrates, and an ethical use disclaimer.
  • You can run your scanner anytime by activating the virtual environment and running python scanner.py.

Pause - I'll come back to this later

Since everything is local files, there is nothing to pause. Your project will be waiting for you whenever you come back.

  • Your virtual environment does not consume memory or CPU when not in use.
  • No background processes are running from this project.
  • When you return, open the project folder in VS Code, activate your virtual environment, and pick up where you left off.

Delete - I don't want to use this again

Remove all project files from your machine. This deletes your scanner code, virtual environment, and any generated reports.

  • Open a terminal and navigate to your Desktop by running:
cd ~/Desktop
  • Delete the entire project folder by running this command (replace the folder name with whatever you named your project folder):
rm -rf stealth-port-scanner

On Windows using PowerShell, use this command instead:

Remove-Item -Recurse -Force .\stealth-port-scanner
  • Confirm the folder is gone by running ls (or dir on Windows). Your project folder should no longer appear in the listing.

Nice Work!

Nice Work!

Nice work! You just built a professional-grade reconnaissance tool from scratch using Python. Your stealth port scanner discovers open services, grabs banners to identify software versions, and generates timestamped reports ready for a portfolio.

You've learned how to:

  • Build a multi-threaded TCP port scanner that uses ThreadPoolExecutor to scan hundreds of ports in seconds instead of minutes.
  • Implement banner grabbing and service enumeration to fingerprint what software is running on open ports.
  • Add stealth timing with jitter, CLI arguments, and automated report generation to transform a script into a professional security utility.
  • Secret Mission: Added a vulnerability lookup feature that cross-references discovered service banners against known CVEs.

Ready to quiz yourself?