A security-engineered platform that turns Nmap into a full scanning service: a modular Python engine, a plugin system for threat-intel / automated response / deep-dive tooling, historical baselining, and an authenticated FastAPI + React web application for launching and reviewing scans.
⚠️ Authorized use only. Scan systems you own or have explicit written permission to test. See SECURITY.md.
📝 Write-up: Hardening my own Nmap web UI: the security holes I shipped, and what actually saved me (dev.to) · source
Most "port scanner" projects wrap nmap in a subprocess call and stop there.
This one is built like a product a security team could actually deploy:
- Defense-in-depth by design — the web tier is authenticated, every scan input is validated, command execution is allowlisted, and secrets are redacted from logs. The threat model is documented, not implied.
- Extensible engine — plugins, exporters, credential providers, and a distributed-worker abstraction are first-class, not bolted on.
- Operable — containerised (non-root), health-checked, CI-tested, and scanned by CodeQL, Bandit, and pip-audit on every push.
flowchart LR
subgraph Client
UI[React + Vite dashboard]
end
subgraph Web tier - FastAPI
AUTH[API-key auth\nX-API-Key / ws token]
API[REST + WebSocket API]
JOBS[JobManager\nbackground tasks]
DD[DeepDiveExecutor\nallowlisted, shell=False]
end
subgraph Engine - portscanner package
CLI[CLI / orchestrator]
SCAN[Nmap runner\nvalidated argv + -- sentinel]
PARSE[XML parser]
PLUG[Plugins: threat-intel,\nauto-responder, deep-dive]
BASE[Baseline store + diff]
end
DB[(SQLite\nscans, schedules, workers)]
NMAP[[nmap binary\ncap_net_raw only]]
UI -->|X-API-Key| AUTH --> API
API --> JOBS --> CLI --> SCAN --> NMAP
CLI --> PARSE --> PLUG --> BASE
JOBS --> DB
API --> DD --> NMAP
API <-->|/ws/status| UI
Request lifecycle: the UI submits a scan (POST /api/scans) with an API key →
JobManager validates input, persists a record, and runs the CLI engine in a
background thread → the engine builds a safe nmap argv, parses the XML, runs
plugins, and writes artifacts + baselines → the UI streams status over
/ws/status and downloads artifacts on completion.
| Layer | Path | Responsibility |
|---|---|---|
| Web API | webapp/main.py |
Routes, auth wiring, SPA serving |
| Auth | webapp/security.py |
API-key generation, validation, WS auth |
| Jobs | webapp/jobs.py |
Scan lifecycle, artifact collection, log redaction |
| Deep-dive | webapp/deepdive.py |
Allowlisted follow-up command execution |
| Models | webapp/models.py |
Pydantic request/response + input validation |
| Engine | portscanner/ |
Nmap runner, parser, plugins, baselines, exporters |
| Frontend | webapp/frontend/ |
React + Vite dashboard |
Full details in SECURITY.md. Highlights:
- Authentication everywhere. Every
/api/*route and the/ws/statusWebSocket require anX-API-Key. The key comes fromPORTSCANNER_API_KEYor is generated toweb_runs/.api_key(mode0600) on first boot — the service is never anonymous.GET /api/healthis intentionally open for health checks. - Input validation. Targets, ports, script names, and extra nmap args are
strictly validated. Flag-like targets and dangerous flags (
--script,-oN,--datadir, …) are rejected, and a--sentinel terminates nmap option parsing before the target (defense in depth against argument injection). - Command execution is allowlisted and runs with
shell=False. Uploaded scripts cannot self-authorize; script upload is disabled unlessPORTSCANNER_ENABLE_SCRIPT_UPLOAD=1. - Secret hygiene. Passwords, tokens, bearer headers, and
secret://references are redacted before scan logs are persisted. - Hardened container. Runs as a non-root user;
cap_net_rawis granted to the nmap binary only, so raw scans work without running the service as root.
| # | Threat | Vector | Mitigation |
|---|---|---|---|
| T1 | Unauthenticated access | Exposed API / WebSocket | API-key auth on all /api routes + WS; constant-time comparison |
| T2 | Remote command / NSE execution | --script=… injected via target/args |
Target & arg validation, dangerous-flag denylist, -- sentinel |
| T3 | Arbitrary code via script upload | POST /api/plugins/scripts |
Disabled by default; uploads never auto-allowlisted |
| T4 | Command injection in deep-dive | Crafted command strings | Explicit allowlist + shell=False + shlex tokenisation |
| T5 | Secret leakage | Scan logs persisted to DB | Redaction pass before persistence; .api_key/creds gitignored |
| T6 | Privilege escalation | Service running as root | Non-root container; capability scoped to nmap binary |
| T7 | Supply-chain drift | Unpinned deps | Pinned requirements.txt; pip-audit + CodeQL in CI |
| T8 | Scan abuse / SSRF-style recon | Unrestricted targets | Auth-gated; deploy behind proxy with source-IP allowlists (see SECURITY.md) |
docker compose up --build
# API + UI on http://localhost:8000
# On first boot the generated API key is printed to the logs and stored at web_runs/.api_keySet your own key instead of the generated one:
PORTSCANNER_API_KEY=$(openssl rand -base64 32) docker compose up --build# Backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export PORTSCANNER_API_KEY=$(openssl rand -base64 32)
uvicorn webapp.main:app --reload # http://127.0.0.1:8000
# Frontend (separate terminal)
cd webapp/frontend
npm install
npm run dev # http://127.0.0.1:5173 (proxies /api to :8000)The UI prompts for the API key on first load and stores it in localStorage.
KEY=$(cat web_runs/.api_key) # or your PORTSCANNER_API_KEY
curl -s http://localhost:8000/api/health # open
curl -s -H "X-API-Key: $KEY" http://localhost:8000/api/scans # authenticated
curl -s -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"target":"scanme.nmap.org","ports":"1-1024","intel":true}' \
http://localhost:8000/api/scans| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Liveness check (unauthenticated) |
POST |
/api/scans |
Submit a scan (same options as the CLI) |
GET |
/api/scans |
List jobs with status, summaries, plugin output |
GET |
/api/scans/{id} |
Inspect a job: logs, diff, artifacts |
GET |
/api/scans/{id}/artifacts/{path} |
Download a generated artifact |
POST |
/api/scans/{id}/deepdive |
Run allowlisted follow-up commands |
WS |
/ws/status?token=KEY |
Live job/worker/deep-dive status stream |
The same engine runs standalone without the web tier.
python3 port_scanner.py --target example.com --ports 1-1024 --intelMore CLI examples (batch, plugins, baselines, credentials, config)
python3 port_scanner.py \
--targets 192.168.1.10 192.168.1.11 \
--ports 1-65535 --concurrency 2 \
--output-dir scans/ --output-json combined.jsonpython3 port_scanner.py --target web.example.com --intel \
--intel-scripts banner,http-title,ssl-certpython3 port_scanner.py --target 192.168.1.42 --intel \
--plugins threat-intel auto-responder deep-dive \
--plugin-config plugin-options.json{
"auto-responder": {"min_severity": "critical", "forbidden_ports": ["23", "3389"]},
"deep-dive": {"commands": {"http": ["nuclei -target {target}:{port}"]}}
}python3 port_scanner.py --targets 192.168.1.10 192.168.1.11 \
--baseline-json baselines/latest.json \
--diff-report diffs/today.json --output-json reports/today.json{ "ssh": {"username": "audit", "password": "secret://ssh_pw"}, "snmp": {"community": "private"} }python3 port_scanner.py --target core-switch.local \
--credential-file creds.json --secret-file secrets.json --plugins deep-divepython3 port_scanner.py --config scan-config.jsonpython3 port_scanner.py --xml-file scan.xml --save-report recap.txt --save-vulns recap.json--save-xml PATH/--save-vulns PATH/--save-report PATH/--output-json PATH--output-dir DIR— per-target*.xmland*.vulns.json--intel [--intel-scripts …]— service enrichment + remediation hints--baseline-json PATH/--diff-report PATH/--baseline-store DIR--plugins … [--plugin-config PATH],--exporters … [--exporter-config PATH]--credential-file PATH/--secret-file PATH/--secret-prefix PREFIX--asset-file PATH,--orchestrator-config PATH,--job-file PATH
Run python3 port_scanner.py --help for the full list.
export PORTSCANNER_API_KEY=test-key-0123456789
pytest -qCI (.github/workflows/) runs on every push and PR:
ci.yml— pytest (Python 3.11 & 3.12), Bandit static security scan, pip-audit dependency vulnerability check, and a frontend build.codeql.yml— GitHub CodeQL analysis for Python and JavaScript/TypeScript, weekly and on PR.
| Variable | Purpose | Default |
|---|---|---|
PORTSCANNER_API_KEY |
API key (16+ chars) | auto-generated to web_runs/.api_key |
PORTSCANNER_ENABLE_SCRIPT_UPLOAD |
Allow POST /api/plugins/scripts |
disabled |
PORTSCANNER_SAFE_CONFIG |
Path to safe scan config | <project>/config_safe.json |
DEEP_DIVE_ALLOWLIST |
Allowed deep-dive commands (CSV or file) | testssl.sh,nmap,nuclei |
DEEP_DIVE_ALLOWLIST_FILE |
JSON/text file of allowed commands | – |
DEEP_DIVE_ALLOW_ALL |
1 disables allowlist enforcement (unsafe) |
disabled |
portscanner/ Core engine (scanner, parser, plugins, baselines, exporters)
webapp/ FastAPI backend (main, security, jobs, deepdive, models)
webapp/frontend/ React + Vite dashboard
tests/, webapp/tests/ Pytest suites
.github/workflows/ CI + CodeQL
Dockerfile, docker-compose.yml Container build (non-root)
SECURITY.md Security & threat model
MIT — see LICENSE (add one if not present).