Flask Nmap Web Scanner – Network Port Scanner Built with Python & Flask

Learn How to Create a Flask-Based Nmap Web Scanner: Step-by-Step Guide for Beginners:

Build a Python Telegram Bot That Monitors Any Website and Alerts You Instantly

In this tutorial, you’ll learn how to build a Flask‑based Nmap web dashboard that lets you run network scans directly from your browser using Python. Instead of running Nmap manually from the command line, you’ll create a simple but powerful interface where you can enter a target IP or subnet, specify a port range, and instantly see open ports, services, and versions in a clean HTML table.

This project is perfect for cybersecurity learners, ethical hackers, DevOps engineers, and Python developers who want a practical Python cybersecurity project for their portfolio. You’ll see how to integrate Nmap, python‑nmap, and Flask to automate port scanning, visualize results, and lay the foundation for more advanced features like scan history, reporting, and dashboards. By the end, you’ll understand how to turn a command‑line scan into a reusable network scanner Python web app that can be extended for real‑world security workflows.

Table of Contents

  1. Introduction
  2. Requirements and Prerequisites
  3. Main Flask application (app.py)
  4. Scan Dashboard (index.html)
  5. Scan History Page (history.html)
  6. Conclusion

Introduction

What Is Flask?

Flask is a lightweight, Python‑based web framework designed for building web applications and APIs quickly and with minimal boilerplate. It follows a “micro‑framework” philosophy: instead of forcing a heavy structure, Flask gives you just the essentials—routing, request/response handling, and templating—so you can decide how to structure your project and which extensions to add.

Key points about Flask:

  • Written in Python, perfect for integrating with Python automation tools like Nmap wrappers.
  • Uses Jinja2 templates to render dynamic HTML pages (e.g., your Nmap scan results table).
  • Ideal for small to medium projects, dashboards, and internal tools that need a simple HTTP interface.

In this tutorial, Flask acts as the web front end for your Nmap automation. The browser sends scan requests to Flask, Flask calls Python code that runs Nmap, and then Flask renders the results back to the user.

What Is Nmap?

Nmap (Network Mapper) is a widely used open‑source tool for discovery and security auditing . It’s designed to scan hosts and networks to find:

  • Which IP addresses are alive.
  • Which ports are open.
  • Which services and versions are running (HTTP, SSH, FTP, databases, etc.).
  • Sometimes even operating system fingerprints and basic vulnerabilities.

Nmap supports many scan types (TCP SYN, TCP connect, UDP, service/version detection, and more) and is a standard tool for penetration testers, network administrators, and security engineers.

In this project, Nmap is the scanning engine. Python and Flask don’t replace Nmap—they automate it, capture its output, and present it in a user‑friendly dashboard.

Why Combine Flask and Nmap?

By combining Flask and Nmap, you turn a command‑line network scanner into a web‑based network scanning dashboard:

  • Flask handles the web UI and HTTP requests.
  • Python code (using python-nmap or subprocess) controls Nmap scans programmatically.
  • The browser displays structured results: host, port, state, service, and version in tables and reports.

This stack gives you a real, practical Python cybersecurity project that demonstrates:

  • Web development with Flask.
  • Network scanning with Nmap.
  • Automation and reporting using Python code and HTML templates.

Requirements and Prerequisites

Before you start building the Flask Nmap dashboard, make sure you have the following in place.

  1. Technical Skills
    • Basic understanding of Python 3 syntax and virtual environments.
    • Familiarity with HTTP, IP addresses, and ports (e.g., 22/SSH, 80/HTTP, 443/HTTPS).
    • Very basic knowledge of Flask (routes, templates) is helpful but not mandatory—this tutorial uses a beginner‑friendly app.py + templates/ structure.
  2. Software Requirements
    • Operating System: Windows, Linux, or macOS.
    • Python 3.x installed and available in your PATH.
    • Nmap installed and accessible from the command line
      • nmap -V should work in your terminal before you run the app.
    • The following Python packages (added via requirements.txt):
      • Flask – to build the web dashboard.
      • python-nmap – to control Nmap from Python.

    You can install all dependencies with:

    pip install -r requirements.txt
    

    with requirements.txt containing:

    Flask
    python-nmap
    colorama
    
  3. Project Structure
  4. You should organize your project similar to this layout to follow along easily:

    flask-nmap-dashboard/
    ├── app.py
    ├── requirements.txt
    └── templates/
        ├── index.html
        └── history.html
    
    • app.py – main Flask application and Nmap integration.
    • templates/index.html – form to submit targets, port ranges and page to display scan results in a table.
    • templates/history.html – page to display scan history.

With these requirements ready, you’re set to start building your Flask Nmap dashboard and automate Nmap scans using Python in a clean, web‑based interface.


Main Flask application

app.py

                            
import json
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path

from flask import (
    Flask, render_template, request, jsonify,
    Response, g
)
import nmap

app = Flask(__name__)

DB_PATH = Path("scan_history.db")


# ---------- DB Helpers (SQLite) ----------

def get_db():
    """
    Get a SQLite connection for the current request.
    Based on Flask's SQLite pattern.[web:64]
    """
    if "db" not in g:
        g.db = sqlite3.connect(DB_PATH)
        g.db.row_factory = sqlite3.Row
    return g.db

@app.teardown_appcontext
def close_db(exception):
    db = g.pop("db", None)
    if db is not None:
        db.close()

def init_db():
    """
    Create the scans table if it does not exist.[web:62]
    """
    db = sqlite3.connect(DB_PATH)
    db.execute(
        """
        CREATE TABLE IF NOT EXISTS scans (
            id TEXT PRIMARY KEY,
            target TEXT NOT NULL,
            ports TEXT NOT NULL,
            timestamp TEXT NOT NULL,
            result_json TEXT NOT NULL
        )
        """
    )
    db.commit()
    db.close()


# ---------- Nmap Logic ----------

def run_nmap_scan(target, ports="1-1024", arguments="-sV"):
    nm = nmap.PortScanner(nmap_search_path=(r"C:\Program Files (x86)\Nmap\nmap.exe",))
    nm.scan(hosts=target, ports=ports, arguments=arguments)

    scan_results = []

    for host in nm.all_hosts():
        host_state = nm[host].state()
        hostname = nm[host].hostname()
        for proto in nm[host].all_protocols():
            for port in sorted(nm[host][proto].keys()):
                pd = nm[host][proto][port]
                scan_results.append({
                    "host": host,
                    "hostname": hostname or "",
                    "state": host_state,
                    "protocol": proto,
                    "port": port,
                    "port_state": pd.get("state", ""),
                    "service": pd.get("name", ""),
                    "product": pd.get("product", ""),
                    "version": pd.get("version", "")
                })

    return scan_results


# ---------- Routes ----------

@app.route("/")
def index():
    return render_template("index.html")


@app.route("/api/scan", methods=["POST"])
def api_scan():
    data = request.get_json() or {}
    target = (data.get("target") or "").strip()
    ports = (data.get("ports") or "").strip() or "1-1024"

    if not target:
        return jsonify({"error": "Target is required."}), 400

    try:
        results = run_nmap_scan(target, ports=ports, arguments="-sV")
    except nmap.PortScannerError as e:
        return jsonify({"error": f"Nmap error: {e}"}), 500
    except Exception as e:
        return jsonify({"error": f"Unexpected error: {e}"}), 500

    timestamp = datetime.utcnow().isoformat() + "Z"
    meta = {"target": target, "ports": ports, "timestamp": timestamp}
    payload = {"meta": meta, "results": results}

    # Persist to SQLite
    scan_id = uuid.uuid4().hex
    db = get_db()
    db.execute(
        "INSERT INTO scans (id, target, ports, timestamp, result_json) VALUES (?, ?, ?, ?, ?)",
        (scan_id, target, ports, timestamp, json.dumps(payload)),
    )
    db.commit()

    return jsonify({
        "scan_id": scan_id,
        "target": target,
        "ports": ports,
        "timestamp": timestamp,
        "count": len(results),
        "results": results
    })

@app.route("/api/download/.", methods=["GET"])
def api_download(scan_id, fmt):
    db = get_db()
    row = db.execute(
        "SELECT result_json FROM scans WHERE id = ?",
        (scan_id,),
    ).fetchone()

    if not row:
        return jsonify({"error": "Invalid or expired scan_id."}), 404
    
    payload = json.loads(row["result_json"])
    meta = payload["meta"]
    results = payload["results"]

    if fmt == "json":
        resp = Response(
            json.dumps(payload, indent=2),
            mimetype="application/json"
        )
        filename = f"scan_{scan_id}.json"
        resp.headers["Content-Disposition"] = f"attachment; filename={filename}"
        return resp
    elif fmt == "csv":
        if not results:
            return jsonify({"error": "No data for this scan."}), 400
        
        import io, csv
        output = io.StringIO()
        fieldnames = ["host", "hostname", "state", "protocol", "port", "port_state", "service", "product", "version"]
        writer = csv.DictWriter(output, fieldnames=fieldnames)
        writer.writeheader()
        for row in results:
            writer.writerow(row)

        csv_data = output.getvalue()
        resp = Response(csv_data, mimetype="text/csv")
        filename = f"scan_{scan_id}.csv"
        resp.headers["Content-Disposition"] = f"attachment; filename={filename}"
        return resp
    else:
        return jsonify({"error": "Unsupported format. Use csv or json."}), 400

@app.route("/history")
def history():
    """
    Simple scan history page: lists previous scans with links.
    """
    db = get_db()
    rows = db.execute(
         "SELECT id, target, ports, timestamp FROM scans ORDER BY timestamp DESC"
    ).fetchall()

    scans = [
         {
             "id": r["id"],
             "target": r["target"],
             "ports": r["ports"],
             "timestamp": r["timestamp"]
         }
         for r in rows
    ]

    return render_template("history.html", scans=scans)


if __name__ == "__main__":
    if not DB_PATH.exists():
        init_db()
    else:
        init_db()
    app.run(host="0.0.0.0", port=5000, debug=True)
                        

The Imports

                            
import json
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path

from flask import Flask, render_template, request, jsonify, Response, g
import nmap
                        

These are all the tools your app needs — covered in detail previously.

Import Role / Purpose
json Serialize/deserialize scan results (JSON storage)
sqlite3 Store and retrieve scan history in a local DB
uuid Generate unique scan IDs for tracking results
datetime Timestamp each scan and handle last-checked logs
pathlib.Path Clean, cross-platform file path handling
flask Web framework core tools for the local dashboard
nmap Python-nmap wrapper to run network scans programmatically

App Instance Creation

                            
app = Flask(__name__)
                        

This creates your Flask web application:

  • Flask(...) — initializes the app object that handles all routing, templates, and requests.
  • __name__ — tells Flask the name of the current Python module so it knows where to find templates and static files (e.g., templates/index.html, static/style.css).

💡 Think of app like the engine of a car — everything else (routes, DB, scan logic) plugs into it.

Database Path

                            
DB_PATH = Path("scan_history.db")
                        
  • Defines the location of your SQLite database file as a pathlib.Path object.
  • scan_history.db will be created in the same folder as app.py when init_db() runs.
  • Using Path instead of a plain string ("scan_history.db") makes it cross-platform — works correctly on both Windows (\) and Linux/Mac (/).

How These Three Lines Connect Everything

                            
app = Flask(__name__)   ← The web app engine
DB_PATH = Path(...)     ← Where scan data is stored

Every route uses `app`     → @app.route("/")
Every DB operation uses DB_PATH → sqlite3.connect(DB_PATH)
                        

These two lines are global — defined once at the top and used throughout the entire app.py file.

get_db() — Open a DB Connection

                            
def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(DB_PATH)
        g.db.row_factory = sqlite3.Row
    return g.db
                        

This function gets (or creates) a SQLite database connection for the current web request.

  • g is Flask's request-scoped storage — it lives only for the duration of one HTTP request.
  • if "db" not in g — checks if a connection already exists; if yes, reuses it (avoids opening multiple connections per request).
  • sqlite3.connect(DB_PATH) — opens the scan_history.db file.
  • g.db.row_factory = sqlite3.Row g.db.row_factory = sqlite3.Row — makes query results behave like dictionaries so you can do row["target"] instead of row[1].

💡 Think of it like: A waiter who brings you one menu per visit, not a new one for every question you ask.

close_db() — Auto-Close DB After Request

                            
@app.teardown_appcontext
def close_db(exception):
    db = g.pop("db", None)
    if db is not None:
        db.close()
                        

This runs automatically after every request ends, whether it succeeded or failed.

  • @app.teardown_appcontext This runs automatically after every request ends, whether it succeeded or failed.
  • g.pop("db", None) — removes the DB connection from g /li> (returns None if no connection was opened).<
  • db.close() — closes the connection, releasing the file lock and freeing memory.

💡 Think of it like: A janitor who automatically cleans up the room after every meeting ends.

init_db() — Create the Table on Startup

                            
def init_db():
    db = sqlite3.connect(DB_PATH)
    db.execute("""
        CREATE TABLE IF NOT EXISTS scans (
            id TEXT PRIMARY KEY,
            target TEXT NOT NULL,
            ports TEXT NOT NULL,
            timestamp TEXT NOT NULL,
            result_json TEXT NOT NULL
        )
    """)
    db.commit()
    db.close()
                        

This creates the scans table in scan_history.db if it doesn't already exist.

Column Type Purpose
id TEXT PRIMARY KEY UUID hex — unique scan identifier
target TEXT NOT NULL IP/subnet that was scanned
ports TEXT NOT NULL Port range used (e.g., 1-1024)
timestamp TEXT NOT NULL UTC time of the scan
result_json TEXT NOT NULL Full scan results as a JSON blob
  • CREATE TABLE IF NOT EXISTS — safe to call multiple times; won't overwrite existing data.
  • db.commit() — saves the table creation to disk.
  • db.close() — closes the direct connection (this runs at startup, before g exists, so it uses a direct connect/close pattern).

How All Three Connect

App starts → init_db() creates table (once)

Request comes in → get_db() opens connection, stores in g
    → scan runs, data saved to DB

Request ends → close_db() auto-runs, closes connection

This is the official Flask SQLite pattern — clean, lightweight, and perfect for your Nmap Dashboard tutorial.

Function Signature

                            
def run_nmap_scan(target, ports="1-1024", arguments="-sV"):
                        

This defines the function with 3 parameters:

Parameter Default Meaning
target (required) IP or subnet to scan (e.g., 192.168.1.1)
ports "1-1024" Port range to scan
arguments "-sV" Nmap flags — -sV means detect service versions

Create the Scanner & Run the Scan

                            
nm = nmap.PortScanner()
nm.scan(hosts=target, ports=ports, arguments=arguments)
                        
  • nm = nmap.PortScanner() — creates a scanner object (wrapper around the Nmap binary).
  • nm.scan(...) — runs the Nmap scan with the specified parameters.

💡 Think of nm like a remote control — nm.scan() is pressing the "Start" button.

Loop Through the Results (3 Nested Loops)

                            
for host in nm.all_hosts():          # Loop 1: each IP that responded
    host_state = nm[host].state()    # "up" or "down"
    hostname = nm[host].hostname()   # Reverse DNS name (if any)

    for proto in nm[host].all_protocols():       # Loop 2: "tcp" or "udp"
        for port in sorted(nm[host][proto].keys()):  # Loop 3: each port number
            pd = nm[host][proto][port]           # Port details dict
                        

The 3 nested loops exist because Nmap's data is structured in layers:

Scan Result
 └── Host (e.g., 192.168.1.5)
      └── Protocol (tcp / udp)
           └── Port (22, 80, 443...)
                └── State, Service, Version...

Extract Port Details & Build a Row

                            
scan_results.append({
    "host": host,              # IP address
    "hostname": hostname or "",# Domain name (empty if none)
    "state": host_state,       # Host is "up" or "down"
    "protocol": proto,         # "tcp" or "udp"
    "port": port,              # Port number (e.g., 80)
    "port_state": pd.get("state", ""),    # "open", "closed", "filtered"
    "service": pd.get("name", ""),        # e.g., "http", "ssh"
    "product": pd.get("product", ""),     # e.g., "Apache httpd"
    "version": pd.get("version", "")      # e.g., "2.4.51"
})
                        
  • pd.get("key", "") — safely extracts the value, returns "" if the key doesn't exist (avoids crashes.
  • Each dict = one row in the results table and one row in the CSV export.

Return All Results

                            
return scan_results
                        

Returns a list of dicts — one dict per open port found. For example, scanning 192.168.1.1 with ports 22,80,443 might return:

json

                            
[
{"host": "192.168.1.1", "port": 22, "service": "ssh", "product": "OpenSSH", "version": "8.9", ...},
{"host": "192.168.1.1", "port": 80, "service": "http", "product": "nginx", "version": "1.22", ...}
]
                        

This list is then serialized to JSON (json.dumps) and stored as a single blob in your SQLite scans table.

Full Flow in One Line

Target IP → Nmap binary runs → XML parsed → 3 loops flatten it → list of port dicts returned

Route 1 — "/" Home Page

                            
@app.route("/")
def index():
    return render_template("index.html")
                        

The simplest route — when a user visits http://localhost:5000/, Flask loads and returns the index.html template (your scan form UI). No logic, just serve the page.

Route 2 — "/api/scan" Run a Scan

This is the core route — the entire scan pipeline lives here.

Read & Validate Input

                            
data = request.get_json() or {}
target = (data.get("target") or "").strip()
ports  = (data.get("ports")  or "").strip() or "1-1024"

if not target:
    return jsonify({"error": "Target is required."}), 400
                        
  • Reads the JSON body sent by the frontend (fetch('/api/scan', {body: JSON.stringify(...)})).
  • {} — prevents crash if no JSON body is sent.
  • .strip() — removes accidental spaces from user input.
  • Returns a 400 Bad Request if target is empty.

Run the Scan (with Error Handling)

                            
try:
    results = run_nmap_scan(target, ports=ports, arguments="-sV")
except nmap.PortScannerError as e:
    return jsonify({"error": f"Nmap error: {e}"}), 500
except Exception as e:
    return jsonify({"error": f"Unexpected error: {e}"}), 500
                        
  • Calls the run_nmap_scan() function you saw earlier.
  • PortScannerError catches Nmap-specific failures (e.g., Nmap not installed, invalid target).
  • The generic Exception catches anything else — two-layer error safety net.

Save to SQLite

                            
scan_id = uuid.uuid4().hex
db = get_db()
db.execute("INSERT INTO scans (...) VALUES (?, ?, ?, ?, ?)", (...))
db.commit()
                        
  • Generates a unique UUID for this scan.
  • Builds a payload dict containing both metadata and results.
  • json.dumps(payload) serializes the entire result as a single JSON blob stored in the result_json column.

Return the Response

                            
return jsonify({
    "scan_id": scan_id, "target": target, "ports": ports,
    "timestamp": timestamp, "count": len(results), "results": results
})
                        

Sends back everything the frontend needs to display the table and build the download links..

Route 3 — "/api/download/." Download Results

                            
@app.route("/api/download/.", methods=["GET"])
def api_download(scan_id, fmt):
                        

Flask captures both scan_id and fmt (file format) directly from the URL. So:

URL Example scan_id fmt (Format)
/api/download/3e4d5f6a.json 3e4d5f6a json
/api/download/3e4d5f6a.csv 3e4d5f6a csv

Fetch from DB

                            
db = get_db()
row = db.execute(
    "SELECT result_json FROM scans WHERE id = ?",
    (scan_id,),
).fetchone()

if not row:
    return jsonify({"error": "Invalid or expired scan_id."}), 404
                        
  • get_db() — opens (or reuses) the SQLite connection for this request.
  • WHERE id = ? — looks up the scan by UUID; the ? is a parameterized query (prevents SQL injection).
  • .fetchone() — retrieves a single row (since id is a PRIMARY KEY, there's at most one match).
  • If nothing is found → returns 404 Not Found with a clear error message.

JSON Download

                            
if fmt == "json":
    resp = Response(
        json.dumps(payload, indent=2),
        mimetype="application/json"
    )
    filename = f"scan_{scan_id}.json"
    resp.headers["Content-Disposition"] = f"attachment; filename={filename}"
    return resp
                        
  • json.dumps(payload, indent=2) — re-serializes the full payload as a pretty-printed JSON string (2-space indentation for readability).
  • mimetype="application/json" — tells the browser this is a JSON file.
  • Content-Disposition: attachment — the critical header that forces the browser to download the file instead of displaying it in the tab.

CSV Download

                            
elif fmt == "csv":
    if not results:
        return jsonify({"error": "No data for this scan."}), 400

    import io, csv
    output = io.StringIO()
    fieldnames = ["host", "hostname", "state", "protocol",
                  "port", "port_state", "service", "product", "version"]
    writer = csv.DictWriter(output, fieldnames=fieldnames)
    writer.writeheader()
    for row in results:
        writer.writerow(row)

    csv_data = output.getvalue()
    resp = Response(csv_data, mimetype="text/csv")
    resp.headers["Content-Disposition"] = f"attachment; filename=scan_{scan_id}.csv"
    return resp
                        

Breaking this down piece by piece:

Line / Component What it Does
if not results Guard against empty scans — returns 400 if no ports were found
io.StringIO() Creates an in-memory text buffer (like a virtual file, no disk I/O needed)
csv.DictWriter(output, fieldnames) Creates a CSV writer that maps dict keys → columns
writer.writeheader() Writes the first row: host,hostname,state,protocol,...
writer.writerow(row) Writes one port result per CSV row
output.getvalue() Extracts the entire CSV as a string from the buffer

Unsupported Format

                            
else:
return jsonify({"error": "Unsupported format. Use csv or json."}), 400
                        

If someone tries /api/download/abc123.xml or any other format, they get a clean 400 Bad Request with a helpful message instead of a server crash.

Full Flow Visualized

│
├── Fetch row from SQLite by UUID
├── 404 if not found
├── json.loads → Python dict
│
├── fmt == "csv"?
│     └── Build CSV in memory (io.StringIO)
│         → Set Content-Disposition header
│         → Browser downloads scan_3e4d5f6a.csv ✅
│
└── fmt == "json"?
      └── json.dumps with indent
          → Set Content-Disposition header
          → Browser downloads scan_3e4d5f6a.json ✅

Route 4 — "/history" Scan History Page

                            
@app.route("/history")
def history():
                        

When a user visits http://localhost:5000/history, Flask calls this function and returns the scan history page. It's a GET-only route (no methods= specified, so Flask defaults to GET).

Fetch All Scans from SQLite

                            
db = get_db()
rows = db.execute(
    "SELECT id, target, ports, timestamp FROM scans ORDER BY timestamp DESC"
).fetchall()
                        
  • get_db() — opens (or reuses) the SQLite connection for this request.
  • SELECT id, target, ports, timestamp — fetches only 4 columns, not result_json (no need to load the heavy scan data just for the history list).
  • ORDER BY timestamp DESC — newest scans appear first (most recent at the top).
  • .fetchall() — retrieves all matching rows as a list (unlike .fetchone() which gets just one).

💡 This is an intentional performance optimization — skipping result_json avoids loading potentially large JSON blobs for every scan just to display a summary list.

Convert Rows to Python Dicts

                            
scans = [
    {
        "id": r["id"],
        "target": r["target"],
        "ports": r["ports"],
        "timestamp": r["timestamp"],
    }
    for r in rows
]
                        

This is a list comprehension — a compact Python loop that builds a new list.

r (sqlite3.Row) Why convert to dict?
Works like a dict already (r["id"]) Jinja2 templates handle plain dicts more reliably.
Tied to the DB connection lifecycle Plain dicts are independent and safe to pass around.

Each dict in scans represents one row in the history table that will be rendered in the HTML.

Render the Template

                            
return render_template("history.html", scans=scans)
                        
  • render_template — loads templates/history.html and processes it with Jinja2.
  • scans=scans — passes the list of scan dicts into the template as a variable.

Inside history.html, Jinja2 loops over it like this:

                            
{% for scan in scans %}
<tr>
    <td>{{ scan.timestamp }}</td>
    <td>{{ scan.target }}</td>
    <td>{{ scan.ports }}</td>
    <td>
        <a href="/api/download/{{ scan.id }}.json">JSON</a>
        <a href="/api/download/{{ scan.id }}.csv">CSV</a>
    </td>
</tr>
{% endfor %}
                        

Each row in the table has direct download links built from the scan.id UUID — connecting the history page directly to the api_download route you saw earlier.

Full Flow

User visits /history
    │
    ├── SQLite → fetch all scans (id, target, ports, timestamp) newest first
    ├── Convert sqlite3.Row objects → plain Python dicts
    └── Pass to history.html → Jinja2 renders HTML table
              │
              └── Each row shows: Timestamp | Target | Ports | [JSON] [CSV]

The route is intentionally simple and read-only — it only reads from the DB, never writes, making it fast and safe.

                            
rows = db.execute(
    "SELECT id, target, ports, timestamp FROM scans ORDER BY timestamp DESC"
).fetchall()
scans = [{"id": r["id"], "target": r["target"], ...} for r in rows]
return render_template("history.html", scans=scans)
                        
  • Fetches all past scans, newest first (ORDER BY timestamp DESC).
  • Converts sqlite3.Row objects into plain Python dicts (easier to use in Jinja2 templates).
  • Passes the list to history.html where Jinja2 loops over it to render the table

App Startup

                            
if __name__ == "__main__":
    if not DB_PATH.exists():
        init_db()
    else:
        init_db()   # Safe: CREATE TABLE IF NOT EXISTS
    app.run(host="0.0.0.0", port=5000, debug=True)
                        
Part Meaning / Role
__name__ == "__main__" Ensures the script only runs when executed directly (e.g., python app.py), not when imported as a module.
init_db() Initializes the SQLite database; creates the scans table if it doesn't already exist (idempotent operation).
host="0.0.0.0" Binds the server to all available network interfaces, making it accessible from other devices in the lab.
port=5000 The specific TCP port the Flask web server listens on for incoming dashboard requests.
debug=True Enables auto-reloading and detailed error pages. ⚠️ Disable in production for security.

Full Request Lifecycle

Browser → POST /api/scan
    → Validate input → run_nmap_scan() → save to SQLite → return JSON

Browser → GET /api/download/abc123.csv
    → Fetch from SQLite → build CSV in memory → send as file download

Browser → GET /history
    → Fetch all scans from SQLite → render history.html table

How Templates Connect to Flask

When Flask calls render_template("index.html") or render_template("history.html", scans=scans), it looks inside the templates/ folder and processes the files using Jinja2 — a templating engine that lets you embed Python-like logic inside HTML.

The Scan Dashboard

index.html

                            
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>Nmap Web Dashboard (Flask + API + Downloads)</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 2rem;
            background: #0f172a;
            color: #e5e7eb;
        }

        h1 {
            margin-bottom: 0.5rem;
        }

        .container {
            max-width: 900px;
            margin: 0 auto;
        }

        label {
            display: block;
            margin-top: 1rem;
            font-weight: 600;
        }

        input[type="text"] {
            width: 100%;
            padding: 0.5rem;
            margin-top: 0.3rem;
            border-radius: 4px;
            border: 1px solid #334155;
            background: #020617;
            color: #e5e7eb;
        }

        button {
            margin-top: 1.5rem;
            padding: 0.6rem 1.4rem;
            border-radius: 4px;
            border: none;
            cursor: pointer;
            background: #22c55e;
            color: #022c22;
            font-weight: 600;
        }

        button:disabled {
            opacity: 0.6;
            cursor: not-allowed;
        }

        .status {
            margin-top: 1rem;
            font-size: 0.9rem;
            min-height: 1.2rem;
        }

        .status.error {
            color: #fca5a5;
        }

        .status.ok {
            color: #6ee7b7;
        }

        .meta {
            margin-top: 1rem;
            font-size: 0.85rem;
            color: #9ca3af;
        }

        table {
            border-collapse: collapse;
            width: 100%;
            margin-top: 1.5rem;
            font-size: 0.9rem;
        }

        th,
        td {
            border: 1px solid #1f2933;
            padding: 0.4rem 0.5rem;
            text-align: left;
        }

        th {
            background: #020617;
        }

        tr:nth-child(even) {
            background: #020617;
        }

        tr:nth-child(odd) {
            background: #020617;
        }

        .open {
            color: #22c55e;
            font-weight: 600;
        }

        .closed {
            color: #f87171;
        }

        .filtered {
            color: #eab308;
        }

        .downloads {
            margin-top: 1rem;
            display: none;
            gap: 0.5rem;
        }

        .downloads a {
            display: inline-block;
            padding: 0.4rem 0.9rem;
            border-radius: 4px;
            background: #1d4ed8;
            color: #e5e7eb;
            text-decoration: none;
            font-size: 0.85rem;
        }
    </style>
</head>

<body>
    <div class="container">
        <h1>Automating Nmap with Python – Web Dashboard</h1>
        <p style="color:#9ca3af;">
            Run Nmap scans from your browser. Backend powered by Flask + python-nmap.
            Use only on networks you own or have permission to scan.
        </p>

        <form id="scan-form">
            <label for="target">Target IP or Subnet</label>
            <input id="target" name="target" type="text" placeholder="e.g. 192.168.1.10 or 192.168.1.0/24"
required>

            <label for="ports">Port Range (optional)</label>
            <input id="ports" name="ports" type="text" placeholder="e.g. 1-1024 or 22,80,443">

            <button type="submit" id="scan-btn">Run Scan</button>
        </form>

        <div class="status" id="status"></div>

        <div class="meta" id="meta"></div>

        <div class="downloads" id="downloads">
            <a id="download-csv" href="#" target="_blank">Download CSV</a>
            <a id="download-json" href="#" target="_blank">Download JSON</a>
        </div>

        <table id="results-table" style="display:none;">
            <thead>
                <tr>
                    <th>Host</th>
                    <th>Hostname</th>
                    <th>Protocol</th>
                    <th>Port</th>
                    <th>State</th>
                    <th>Service</th>
                    <th>Product</th>
                    <th>Version</th>
                </tr>
            </thead>
            <tbody id="results-body">
            </tbody>
        </table>
        <p style="margin-top:1rem;font-size:0.85rem;">
            <a href="{{ url_for('history') }}" style="color:#60a5fa;">View scan history</a>
        </p>
    </div>

    <script>
        const form = document.getElementById("scan-form");
        const btn = document.getElementById("scan-btn");
        const statusEl = document.getElementById("status");
        const metaEl = document.getElementById("meta");
        const table = document.getElementById("results-table");
        const tbody = document.getElementById("results-body");
        const downloadsBox = document.getElementById("downloads");
        const downloadCsvLink = document.getElementById("download-csv");
        const downloadJsonLink = document.getElementById("download-json");

        let lastScanId = null;

        form.addEventListener("submit", async (e) => {
            e.preventDefault();

            const target = document.getElementById("target").value.trim();
            const ports = document.getElementById("ports").value.trim();

            if (!target) {
                statusEl.textContent = "Target is required.";
                statusEl.className = "status error";
                return;
            }

            btn.disabled = true;
            statusEl.textContent = "Running scan... this may take a moment.";
            statusEl.className = "status";

            metaEl.textContent = "";
            table.style.display = "none";
            tbody.innerHTML = "";
            downloadsBox.style.display = "none";
            lastScanId = null;

            try {
                const resp = await fetch("/api/scan", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ target, ports })
                });

                const data = await resp.json();

                if (!resp.ok) {
                    statusEl.textContent = data.error || "Scan failed.";
                    statusEl.className = "status error";
                    return;
                }

                statusEl.textContent = `Scan completed. ${data.count} results.`;
                statusEl.className = "status ok";

                metaEl.textContent = `Target: ${data.target} | Ports: ${data.ports} | Time: ${data.timestamp}`;

                if (data.results && data.results.length > 0) {
                    data.results.forEach(r => {
                        const tr = document.createElement("tr");

                        tr.innerHTML = `
            <td>${r.host}</td>
            <td>${r.hostname || ""}</td>
            <td>${r.protocol}</td>
            <td>${r.port}</td>
            <td class="${r.port_state}">${r.port_state}</td>
            <td>${r.service || ""}</td>
            <td>${r.product || ""}</td>
            <td>${r.version || ""}</td>
          `;

                        tbody.appendChild(tr);
                    });
                    table.style.display = "table";
                } else {
                    table.style.display = "none";
                }

                // Enable download links for this scan_id
                lastScanId = data.scan_id;
                if (lastScanId) {
                    downloadCsvLink.href = `/api/download/${lastScanId}.csv`;
                    downloadJsonLink.href = `/api/download/${lastScanId}.json`;
                    downloadsBox.style.display = "flex";
                }

            } catch (err) {
                console.error(err);
                statusEl.textContent = "Error calling API: " + err;
                statusEl.className = "status error";
            } finally {
                btn.disabled = false;
            }
        });
    </script>
</body>

</html>
                        

This is the main page served at http://localhost:5000/. It has three major sections:

1. The Header / Navigation

Run Nmap scans from your browser.
Backend powered by Flask + python-nmap.
Use only on networks you own or have permission to scan.
→ View scan history
  • Displays a legal disclaimer — critical for an ethical hacking tool, reminding users to only scan networks they own or have permission to test.
  • A "View scan history" link navigates to /history — the history() route in Flask.

2. The Scan Form

This is where the user enters:

  • Target — IP address or subnet (e.g., 192.168.1.1 or 192.168.1.0/24).
  • Ports — port range (defaults to 1-1024).
  • A "Start Scan" button that triggers the JavaScript fetch request.

3. The JavaScript Fetch (Core Frontend Logic)

When the user clicks "Start Scan", the JS does this:

                            
// Step 1 — Read form values
const target = document.querySelector('#target').value;
const ports  = document.querySelector('#ports').value;
// Step 2 — POST to /api/scan
const response = await fetch('/api/scan', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ target, ports })
});
// Step 3 — Parse and display results
const data = await response.json();
// Renders results table + download links using scan_id
                        
Step What Happens
User clicks Scan JS reads form values and validates the input targets.
fetch('/api/scan') Sends a POST request to the Flask backend with JSON payload.
Flask runs Nmap Backend executes the scanner and returns JSON containing scan_id, results, and count.
JS renders table The browser dynamically builds HTML rows for every open port discovered.
Download links built The UI updates download buttons using the scan_id to point to /api/download/{scan_id}.csv and .json.

4. Results Table

The results table is dynamically built by JavaScript (not Jinja2) because the scan runs asynchronously after page load. Each row shows:

Host | Hostname | Protocol | Port | State | Service | Product | Version

This maps exactly to the dict keys returned by run_nmap_scan() in your Python backend.


The Scan History Page

history.html

                            
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Nmap Scan History</title>
  <style>
    body { font-family: Arial, sans-serif; margin: 2rem; background:#0f172a; color:#e5e7eb; }
    h1 { margin-bottom: 0.5rem; }
    a { color:#60a5fa; text-decoration:none; }
    a:hover { text-decoration:underline; }
    table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-size: 0.9rem; }
    th, td { border: 1px solid #1f2933; padding: 0.4rem 0.5rem; text-align: left; }
    th { background: #020617; }
    tr:nth-child(even) { background:#020617; }
    tr:nth-child(odd) { background:#020617; }
    .actions a { margin-right:0.5rem; }
  </style>
</head>
<body>
  <h1>Nmap Scan History</h1>
  <p><a href="{{ url_for('index') }}">&larr; Back to Dashboard</a></p>

  {% if scans %}
    <table>
      <thead>
        <tr>
          <th>Timestamp (UTC)</th>
          <th>Target</th>
          <th>Ports</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
      {% for s in scans %}
        <tr>
          <td>{{ s.timestamp }}</td>
          <td>{{ s.target }}</td>
          <td>{{ s.ports }}</td>
          <td class="actions">
            <a href="{{ url_for('api_download', scan_id=s.id, fmt='csv') }}">CSV</a>
            <a href="{{ url_for('api_download', scan_id=s.id, fmt='json') }}">JSON</a>
          </td>
        </tr>
      {% endfor %}
      </tbody>
    </table>
  {% else %}
    <p>No scans recorded yet.</p>
  {% endif %}
</body>
</html>
                        

This page is served at http://localhost:5000/history and uses Jinja2 templating (server-side rendering, unlike index.html which uses client-side JS).

Jinja2 Conditional Block

                            
{% if scans %}
    
{% else %}
    No scans recorded yet.
{% endif %}
                        
  • {% if scans %} — Jinja2 checks if the scans list passed from Flask is non-empty.
  • If empty → shows a friendly "No scans recorded yet." message instead of a blank table.

The Table Loop

                            
{% for s in scans %}
<tr>
    <td>{{ s.timestamp }}</td>   <!-- e.g., 2026-05-07T12:03:22Z -->
    <td>{{ s.target }}</td>      <!-- e.g., 192.168.1.1 -->
    <td>{{ s.ports }}</td>       <!-- e.g., 1-1024 -->
    <td>
        <a href="/api/download/{{ s.id }}.csv">CSV</a>
        <a href="/api/download/{{ s.id }}.json">JSON</a>
    </td>
</tr>
{% endfor %}
                        
  • {% for s in scans %} — Jinja2 loop over the list Flask passed in.
  • {{ s.timestamp }} — Jinja2 expression — outputs the value of s["timestamp"] into HTML.
  • The CSV and JSON download links are built using s.id — connecting directly to your api_download() Flask route.

Navigation

← Back to Dashboard

A simple link back to / (the index() route) — keeping navigation between the two pages clean.

Full Two-Page Flow

Flask starts → init_db() creates scan_history.db
       │
       ▼
User visits /  →  render_template("index.html")
       │               Frontend JS handles all scan logic
       │               POST /api/scan → results rendered in browser
       │               Download links: /api/download/{id}.csv/.json
       │
       ▼
User visits /history  →  history() fetches scans from SQLite
                          render_template("history.html", scans=scans)
                          Jinja2 loops over scans → builds HTML table
                          Each row has CSV + JSON download links

The key split: index.html uses JavaScript (client-side) to dynamically show scan results without reloading the page, while history.html uses Jinja2 (server-side) to render the table from data Flask passes in.

Project Conclusion

By now, you’ve built a fully working Flask‑based Nmap web dashboard that can scan targets, enumerate open ports, and display services and versions directly in the browser using Python. Instead of running single‑use commands in a terminal, you now have a reusable network scanner Python app that combines Flask, python-nmap, and HTML templates to automate and visualize Nmap results in a clean, user‑friendly interface.

This project is a solid foundation for more advanced Python cybersecurity projects. You can extend it with scan history, CSV/JSON export, authentication, role‑based access, or integration into a larger security monitoring stack. Whether you are a cybersecurity student, ethical hacker, or backend developer, this Flask Nmap tutorial gives you a practical, portfolio‑ready example of how to turn core security tools into real‑world web applications that your team can actually use.

Other Projects

Space Shooter Game Python Pygame Tutorial

Shooter Game

This is a beginner-friendly guide for building a Space Shooter game with Python and Pygame, covering coding concepts and project structure.

Python Pygame
View Project
ATM Management System Python Tutorial

ATM Management System

This Python application implements a multi-user ATM system with SQLite-backed persistence, featuring account management, financial transactions, and administrative controls.

Python SQLite
View Project
Weather App HTML CSS JavaScript Tutorial

Weather App

Responsive weather app with real-time API data, feature comparison, and intuitive design for global city forecasts.

HTML CSS JavaScript
View Project
Team Card App HTML CSS JavaScript Tutorial

Team Card App

Interactive team card application for cricket, featuring dynamic team selection, player filters, and customizable light/dark themes.

HTML CSS JavaScript
View Project
Password Strength Checker C++ Tutorial

Password Strength Checker

Multi-Password Batch Strength Checker (C++), designed to check multiple passwords at once, show individual strength, and provide a summary report.

C++
View Project
VPN Connectivity verification in C Tutorial

VPN Connectivity verification in C

Efficient C program to verify VPN status, routing, and DNS configurations through comprehensive public IP and network adapter analysis.

C
View Project