__ __ _ _____
| \/ | | | / ____|
| \ / | ___ __| |_ | (___ ___ __ _ _ __
| |\/| |/ _ \ / _` | | | |\___ \ / __/ _` | '_ \
| | | | (_) | (_| | |_| |____) | (_| (_| | | | |
|_| |_|\___/ \__,_|\__,_|_____/ \___\__,_|_| |_|
A production-grade, plugin-based security scanner built in Python.
Port scanning · Web app testing · CVE enrichment · Multi-format reporting
Built by Silence · v1.0.0
For authorized penetration testing and security research only.
- Overview
- Why I Built This
- Build Process & Architecture Decisions
- Features
- Project Structure
- Installation
- Quick Start
- CLI Reference
- Module Deep Dives
- Configuration
- Adding a Module
- Sample Output
- Technical Skills Demonstrated
- Roadmap
- Legal Disclaimer
ModuScan is a self-contained, modular vulnerability scanner written entirely in Python. It combines network-layer scanning (via nmap), web application testing (SQLi, XSS, LFI, CORS, security headers), CVE enrichment from the NIST NVD API, and professional multi-format reporting into a single cohesive tool with a polished CLI.
The project was designed from the ground up with a plugin architecture — every scanner capability is an independent module that can be enabled, disabled, or replaced without touching any other part of the codebase. This makes it easy to extend, easy to test, and practical to use in real assessments.
moduscan scan 192.168.1.100 --full --report pdf,html
moduscan scan 10.0.0.0/24 --top-ports 1000 --threads 100
moduscan scan https://example.com --web --no-ports --severity HIGH
moduscan report diff scan_before.json scan_after.json
Most open-source vulnerability scanners fall into one of two categories: they are either overly complex frameworks that require significant setup, or simple scripts that do one thing but can't be composed into a full workflow. I wanted to build something that sat between those extremes — a tool that a security practitioner could actually use day-to-day, with clean output, proper rate limiting, real CVE data, and reports they could hand to a client.
The secondary goal was to build something that demonstrated the full stack of skills I use professionally: async Python, CLI design, REST API integration, HTML/CSS report generation, data modeling, and plugin system design — all in one coherent project.
This section documents how the project was actually built, the decisions made along the way, and why. This is the part that matters for understanding the code as a piece of engineering rather than just a tool.
The first decision was how to handle configuration. Many tools hard-code values or use a flat config file. I chose Python dataclasses loaded from environment variables via python-dotenv. This gives:
- Type safety — every setting has an explicit Python type, so passing a string where an int is expected fails loudly at startup rather than silently at runtime.
- Composability — the top-level
Configobject contains nested sub-configs (NetworkConfig,WebScanConfig,APIConfig, etc.) that can be passed directly to the module that needs them. No module needs to know about settings it doesn't use. - 12-factor compliance — all secrets (API keys) come from the environment, never from committed files.
# config.py — each section is its own dataclass
@dataclass
class NetworkConfig:
max_threads: int = int(os.getenv("MODUSCAN_THREADS", "50"))
http_timeout: float = float(os.getenv("MODUSCAN_HTTP_TIMEOUT", "10"))
rate_limit_rps: float = float(os.getenv("MODUSCAN_RATE_LIMIT", "10"))The master Config class composes all of these and runs post-init logic (e.g., auto-disabling the Shodan module when no API key is present).
Before writing any scanner, I needed a shared data contract — a single Finding dataclass that every module returns, regardless of what it scanned. This was the most important architectural decision in the whole project.
@dataclass
class Finding:
title: str
severity: Severity # CRITICAL / HIGH / MEDIUM / LOW / INFO
description: str
target: str
module: str
evidence: str = ""
remediation: str = ""
cvss_score: float = 0.0
cve_ids: list[str] = field(default_factory=list)
references: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
raw: dict = field(default_factory=dict)By standardising on this output format early, I could write the report generator, the CLI display logic, and the diff tool all against a stable interface — and add new scanner modules later without changing any of those downstream components.
The utils.py module also provides:
build_session()— pre-configuredrequests.Sessionwith retry/backoffbuild_async_client()—httpx.AsyncClientfor async scanningrun_tasks_with_semaphore()— bounded async concurrency primitiveclassify_target()— detects whether input is IP, CIDR, domain, or URL@with_retry,@rate_limited,timer()— reusable decorators
The network scanner wraps python-nmap with several important additions:
Structured output types. Rather than returning nmap's raw dict, the scanner parses everything into typed ServiceInfo, PortResult, and ScanResult dataclasses. This means downstream code (the CVE lookup, the report generator) works with clean objects instead of fragile dict key lookups.
Banner grabbing. nmap's -sV flag does version detection but sometimes misses banners or returns incomplete data. The scanner also opens raw TCP connections and reads the first 1024 bytes, then parses those banners with regexes to fill in missing service info.
Vulnerability signature database. Rather than just listing open ports, the scanner checks detected service versions against a library of 15 VulnSignature rules — each with two regexes (service name pattern, version pattern) and full CVE metadata. A port only matches a rule when both regexes fire, keeping false-positive rates low.
@dataclass
class VulnSignature:
service_re: str # matched against service name/product
version_re: str # matched against full version string
title: str
severity: Severity
cvss_score: float
cve_ids: list[str]
remediation: strThread pool design. Each host gets its own nmap.PortScanner() instance (they are not thread-safe when shared). The ThreadPoolExecutor is bounded by cfg.network.max_threads and uses as_completed() so results stream in as they finish rather than waiting for the slowest host.
The NIST NVD API has strict rate limits: 5 requests per 30 seconds without an API key, 50 with one. I built a token-bucket rate limiter as a thread-safe class that sleeps precisely the deficit duration when the bucket runs dry — no busy-waiting, no dropped requests.
class _TokenBucket:
def consume(self, tokens: int = 1) -> None:
with self._lock:
# Refill based on elapsed time, then sleep deficit if needed
self._allowance += elapsed * (self._rate / self._per)
if self._allowance < tokens:
sleep_s = deficit * (self._per / self._rate)
time.sleep(sleep_s)Every API response is written to a disk cache (.cve_cache/<key>.json) with a 24-hour TTL. Repeated scans against the same targets are instant and work offline.
The local CVE database (data/cve_db.json) ships with 7 seed entries for the most critical CVEs (Log4Shell, Spring4Shell, BlueKeep, EternalBlue, Heartbleed, etc.) and grows automatically as NVD responses are written back to it. This means the fallback database gets richer over time without any manual curation.
The query hierarchy is: disk cache → NVD API → local DB. The module degrades gracefully at every step.
The web scanner is built around a plugin system within a module. Every check (SQLi, XSS, LFI, etc.) is a CheckPlugin subclass with:
class CheckPlugin(abc.ABC):
name: str # machine-readable ID
enabled_by: str # maps to a bool in WebScanConfig
@abc.abstractmethod
def run(self, ctx: ScanContext) -> list[Finding]: ...Adding a new check is three steps: subclass, implement run(), append to REGISTRY. The orchestrator loops the registry automatically.
The crawler runs first. Before any injection checks, a BFS crawler walks the target site (bounded by cfg.web.crawl_depth), collecting all same-origin links and form definitions. Every subsequent check works against the full discovered surface, not just the root URL.
Payload design. Rather than random fuzzing, each check uses a curated payload list:
- SQLi: 16 error-based payloads + 5 time-based sleep payloads (with 85% timing threshold to account for network jitter)
- XSS: 14 payloads covering script injection, attribute injection, SVG handlers, and filter-bypass variants
- LFI: 15 traversal encodings including plain, URL-encoded, double-encoded, and UTF-8 overlong sequences
- Open redirect: 28 parameter names × 9 redirect payloads
Sensitive file probing uses HEAD requests first (no body, lower bandwidth) and only falls back to GET on 405. It runs inside a ThreadPoolExecutor so all 60+ paths are probed concurrently.
OWASP ZAP integration auto-activates when ZAP_API_URL is set in the environment — no code changes needed. The ZapCheck plugin spiders, passive-scans, and optionally active-scans through ZAP's proxy, then converts every ZAP alert into a Finding.
Four output formats, all driven by a single ReportData value object:
HTML is a fully self-contained single file. All structural CSS is inlined; Bootstrap 5, Chart.js, and Tablesort load from CDN on open. The design is dark-themed with severity color-coding throughout. Key sections: hero with risk score gauge, metadata cards, Chart.js doughnut + bar charts, executive summary (auto-generated prose from stats), sortable findings table, per-finding accordion with evidence/remediation, module breakdown cards.
PDF is rendered by WeasyPrint directly from the HTML string with injected @page CSS — A4 sizing, page-number footer via CSS counters, all accordion panels forced open, charts hidden (canvas can't render to static PDF).
JSON is a full structured dump including scan metadata, severity summary, and all Finding objects — suitable for SIEM ingestion, CI/CD assertions, or the report diff command.
CSV is UTF-8 BOM encoded (Excel-compatible), one row per finding, list fields semicolon-joined.
The SeverityStats class computes a weighted risk score (0–100):
score = (CRITICAL × 10) + (HIGH × 6) + (MEDIUM × 3) + (LOW × 1)
This gives a single number that meaningfully reflects both the count and severity distribution of findings.
I chose Typer over argparse for three reasons:
- Native Rich integration —
--helpoutput is coloured and formatted automatically Annotatedtype hints serve as both validation and self-documentationcount=Truefor-v/-vv/-vvvverbosity levels works correctly out of the box
The scan orchestrator is an asyncio event loop that runs (host × module) combinations in sequence per host, with a Rich Progress bar that updates its description live as each task starts. Each completed task prints a one-liner with emoji severity counts and elapsed time — giving the user real-time feedback without scrolling past pages of logs.
Exit codes are CI/CD-friendly: 0 = clean, 1 = HIGH findings, 2 = CRITICAL findings, 130 = interrupted.
| Feature | Details |
|---|---|
| Network scanning | nmap integration, service detection, OS fingerprinting, banner grabbing |
| Vuln signatures | 15 built-in rules: vsftpd backdoor, EternalBlue, BlueKeep, Log4Shell-era services, and more |
| Web scanning | SQLi (error + time-based), XSS, LFI, open redirect, 60+ sensitive paths, security headers, CORS |
| CVE enrichment | NVD API v2 with token-bucket rate limiting, 24h disk cache, offline fallback DB |
| Reports | Self-contained HTML (Bootstrap + Chart.js), PDF (WeasyPrint), JSON, CSV |
| Plugin system | Every check is a CheckPlugin subclass — add new checks without touching existing code |
| OWASP ZAP | Optional active/passive scan integration via ZAP_API_URL env var |
| CIDR scanning | Automatically expands 10.0.0.0/24 into individual hosts |
| Rate limiting | Per-host request rate limiting, NVD API token bucket, configurable everywhere |
| Rich CLI | Typer + Rich: coloured output, live progress bars, severity-coded tables, panels |
| Exit codes | 0 clean / 1 HIGH / 2 CRITICAL — suitable for CI/CD pipelines |
ModuScan/
│
├── main.py ← Typer CLI — all commands and orchestration
├── config.py ← Typed dataclass config, env-driven
├── requirements.txt ← All dependencies, pinned
├── .env.example ← API key template (copy to .env)
│
├── modules/
│ ├── __init__.py
│ ├── utils.py ← Finding, Severity, HTTP helpers, decorators
│ ├── network_scanner.py ← nmap port scan, banner grab, vuln signatures
│ ├── web_scanner.py ← Plugin-based web vuln checks + ZAP
│ ├── cve_lookup.py ← NVD API, rate limiter, disk cache, local DB
│ └── report_generator.py ← HTML, PDF, JSON, CSV renderers
│
├── data/
│ └── cve_db.json ← Local CVE database (auto-seeded, grows over time)
│
├── reports/ ← Generated reports (auto-created)
├── logs/ ← Log files (auto-created, 7-day rotation)
└── .cve_cache/ ← CVE API response cache (auto-created, 24h TTL)
Requirements: Python 3.11+, nmap installed on the system.
# 1. Clone the repository
git clone https://github.com/YOUR_USERNAME/ModuScan.git
cd ModuScan
# 2. Create and activate a virtual environment (recommended)
python -m venv .venv
source .venv/bin/activate # Linux/macOS
.venv\Scripts\activate # Windows
# 3. Install Python dependencies
pip install -r requirements.txt
# 4. Install nmap (required for network scanning)
# Linux:
sudo apt-get install nmap
# macOS:
brew install nmap
# Windows: https://nmap.org/download.html
# 5. Configure API keys (optional — scanner works without them)
cp .env.example .env
# Edit .env and add your keysOptional dependencies for enhanced features:
# PDF report generation
pip install weasyprint
# OWASP ZAP integration
pip install python-owasp-zap-v2.4# Scan a single host with default modules (network, web, cve)
python main.py scan 192.168.1.100
# Full scan — all modules, PDF + HTML reports
python main.py scan 192.168.1.100 --full --report pdf,html
# Subnet scan — top 1000 ports, 100 threads
python main.py scan 10.0.0.0/24 --top-ports 1000 --threads 100
# Web-only scan — skip port scanning, show HIGH+ findings only
python main.py scan https://example.com --web --no-ports --severity HIGH
# Specific modules + custom output directory
python main.py scan example.com --modules web,cve --report html -o ./results
# Dry-run — validate inputs and print scan plan without executing
python main.py scan 10.0.0.1 --full --dry-run
# View a saved report in the terminal
python main.py report view ./reports/moduscan_example.com_20240101.json
# Compare two scans — highlights new and resolved findings
python main.py report diff scan_before.json scan_after.json
# Convert a JSON report to PDF
python main.py report convert scan.json --format pdf
# List available modules
python main.py modules
# Show current configuration
python main.py configmoduscan scan TARGET [OPTIONS]
Arguments:
TARGET IP address, CIDR range, domain name, or URL
Scope:
--full Run ALL available modules
--web Include web application scanner
--no-ports Skip network/port scanning
--modules, -m TEXT Comma-separated module list
Available: network, web, cve, dns, ssl, whois, shodan
Port Options:
--top-ports N Scan the N most common TCP ports
--ports, -p TEXT Explicit ports: '80,443' or '1-1024'
--all-ports Scan all 65535 ports
--udp Enable UDP scanning (requires root)
Output:
--report, -r TEXT Formats: html,pdf,json,csv (comma-separated)
--output, -o DIR Report output directory [default: ./reports]
--severity, -s LEVEL Min severity to display: INFO LOW MEDIUM HIGH CRITICAL
Performance:
--threads, -t N Thread pool size [default: 50]
--timeout SECS HTTP/connect timeout [default: 10]
--rate-limit RPS Requests per second per host [default: 10]
--proxy URL HTTP/HTTPS proxy URL
Behaviour:
--dry-run Print plan and exit without scanning
--no-banner Suppress ASCII banner
--verbose, -v Increase verbosity (repeat: -v -vv -vvv)
moduscan report view FILE [--severity LEVEL] [--full]
View a JSON report in the terminal. --full shows evidence and remediation.
moduscan report diff BEFORE AFTER
Compare two JSON reports. Exits 1 if new findings exist.
moduscan report convert FILE [--format FORMATS] [--output DIR]
Re-render a JSON report to HTML, PDF, or CSV.
moduscan modules [--verbose] List available scanner modules
moduscan config [--show-keys] Show current configuration
moduscan --version Print version
moduscan --help Show help
Uses python-nmap with -sV (service detection) and -O (OS fingerprinting). For each open port, a raw TCP socket banner grab supplements nmap data. Service info is parsed into typed ServiceInfo, PortResult, and ScanResult dataclasses.
Built-in vulnerability signatures (15 rules):
| Service | CVE | Severity |
|---|---|---|
| vsftpd 2.3.4 | CVE-2011-2523 (backdoor) | CRITICAL 10.0 |
| Apache 2.4.49/50 | CVE-2021-41773 / CVE-2021-42013 (RCE) | CRITICAL 9.8 |
| RDP exposed | CVE-2019-0708 (BlueKeep) | CRITICAL 9.8 |
| Exim < 4.92 | CVE-2019-10149 (root RCE) | CRITICAL 9.8 |
| SMB exposed | CVE-2017-0144 (EternalBlue) | HIGH 8.1 |
| IIS 5–8 | CVE-2017-7269 | HIGH 8.1 |
| MSSQL exposed | Generic auth risk | HIGH 7.2 |
| VNC exposed | Generic auth risk | HIGH 7.5 |
| Telnet | Cleartext credentials | HIGH 7.4 |
| OpenSSH < 7.2 | CVE-2016-6210 (user enum) | MEDIUM 5.3 |
| ProFTPD 1.3.x | CVE-2009-0542 (SQLi) | HIGH 7.5 |
| Apache EoL | End-of-life branch | HIGH 7.5 |
| MySQL exposed | Exposure risk | MEDIUM 5.9 |
| nginx outdated | Outdated version | MEDIUM 5.3 |
| FTP exposed | Cleartext protocol | MEDIUM 5.3 |
Plugin-based architecture. Built-in plugins:
| Plugin | What it tests |
|---|---|
SqlInjectionCheck |
Error-based (16 payloads, 15 DB error signatures) + time-based blind (5 sleep payloads) + form POST injection |
XssCheck |
14 reflected XSS payloads in GET params and form fields |
DirectoryTraversalCheck |
15 path traversal encodings (plain, URL-encoded, double-encoded, UTF-8 overlong) |
OpenRedirectCheck |
28 redirect param names × 9 external redirect payloads |
SensitiveFilesCheck |
60+ paths: admin panels, .env, git repos, backups, phpMyAdmin, Spring actuators, SSH keys |
SecurityHeadersCheck |
8 required headers (HSTS, CSP, X-Content-Type-Options, etc.) + leaky Server/X-Powered-By |
CorsCheck |
Wildcard ACAO + arbitrary origin reflection with credential escalation |
ZapCheck |
OWASP ZAP spider + passive + optional active scan (set ZAP_API_URL) |
Query methods:
lookup_product(product, version)— keyword search against NVDlookup_cpe(cpe_string)— exact CPE 2.3 URI matchlookup_cve_id(cve_id)— direct CVE-ID fetchlookup_batch([(product, version), ...])— multi-product batchenrich_findings(cve_ids, target)— converts CVE-IDs to full Findings
NVD rate limits respected automatically:
- Without API key: 5 req / 30s
- With
NVD_API_KEY: 50 req / 30s
SeverityStats.risk_score = (CRITICAL×10) + (HIGH×6) + (MEDIUM×3) + (LOW×1)
Capped at 100. Labels: CLEAN / LOW RISK / MEDIUM RISK / HIGH RISK / CRITICAL RISK
HTML report sections: hero with risk gauge, scan metadata cards, severity doughnut chart, findings-by-module bar chart, auto-generated executive summary, sortable findings table, per-finding accordion (description, evidence, remediation, CVE links, tags), module breakdown, footer.
All settings are configurable via environment variables. Copy .env.example to .env and set values there, or export them in your shell.
| Variable | Default | Description |
|---|---|---|
MODUSCAN_THREADS |
50 |
Thread pool size |
MODUSCAN_HTTP_TIMEOUT |
10 |
HTTP timeout (seconds) |
MODUSCAN_DNS_TIMEOUT |
5 |
DNS timeout (seconds) |
MODUSCAN_RATE_LIMIT |
10 |
Requests/sec per host |
MODUSCAN_MAX_RETRIES |
3 |
HTTP retry count |
MODUSCAN_OUTPUT_DIR |
./reports |
Report output directory |
MODUSCAN_FORMATS |
json,html |
Default report formats |
MODUSCAN_MIN_SEVERITY |
0.0 |
Minimum CVSS score to report |
MODUSCAN_LOG_LEVEL |
INFO |
Log level (DEBUG/INFO/WARNING) |
MODUSCAN_CVE_CACHE_TTL_HOURS |
24 |
NVD response cache TTL |
NVD_API_KEY |
— | NIST NVD API key (increases rate limit 10×) |
SHODAN_API_KEY |
— | Shodan API key |
VIRUSTOTAL_API_KEY |
— | VirusTotal API key |
SECURITYTRAILS_API_KEY |
— | SecurityTrails API key |
ZAP_API_URL |
— | OWASP ZAP proxy URL (enables ZAP module) |
ZAP_API_KEY |
— | ZAP API key |
ZAP_ACTIVE_SCAN |
false |
Enable ZAP active scanning |
Every scanner module is a Python file in modules/ that exposes one async function:
# modules/my_scanner.py
from modules.utils import Finding, Severity
from config import Config
async def scan(target: str, cfg: Config) -> list[Finding]:
findings = []
# ... your scanning logic ...
findings.append(Finding(
title = "Vulnerability Title",
severity = Severity.HIGH,
description = "What was found and why it matters.",
target = target,
module = "my_scanner",
evidence = "Raw snippet or request/response",
remediation = "How to fix it.",
cvss_score = 7.5,
cve_ids = ["CVE-2024-XXXXX"],
references = ["https://nvd.nist.gov/vuln/detail/CVE-2024-XXXXX"],
tags = ["my-tag", "category"],
))
return findingsThen register it in main.py:
MODULE_REGISTRY["my_scanner"] = {
"label": "My Scanner",
"desc": "What it does in one sentence.",
"import": "modules.my_scanner",
"icon": "🔎",
}That's all. The CLI, progress bars, report generator, and diff tool all work automatically with any module that returns list[Finding].
Terminal (scan in progress):
__ __ _ _____ ...
ModuScan v1.0.0 · by Silence
╭─ Scan Plan ──────────────────────────────────────────╮
│ Target 192.168.1.100 (ip) │
│ Hosts 1 │
│ Modules 🔌 network 🌐 web 🛡 cve │
│ Ports common ports │
│ Output ./reports │
╰──────────────────────────────────────────────────────╯
──────────────────── Scanning ──────────────────────────
⠸ 🔌 network 192.168.1.100 ████████░░ 4/6 0:00:12
🔌 network 192.168.1.100 🔴 1 🟠 2 🟡 1 8.4s
🌐 web 192.168.1.100 🟠 1 🟡 3 12.1s
🛡 cve 192.168.1.100 🔴 2 2.3s
──────────────────── Results 22.8s ─────────────────────
╭─────────────────────── Scan Summary ───────────────────────╮
│ 🔴 CRITICAL 3 🟠 HIGH 3 🟡 MEDIUM 4 │
│ │
│ Risk Score 74 / 100 — CRITICAL RISK │
╰─────────────────────────────────────────────────────────────╯
Risk score output is a weighted sum: CRITICAL×10 + HIGH×6 + MEDIUM×3 + LOW×1, capped at 100.
Exit codes for CI/CD:
moduscan scan $TARGET --severity HIGH
echo $? # 0 = clean, 1 = HIGH findings, 2 = CRITICAL findings| Skill | Where |
|---|---|
Async Python (asyncio, httpx) |
orchestrate() in main.py, web_scanner.py, cve_lookup.py |
| Plugin / strategy pattern | CheckPlugin base class in web_scanner.py |
Data modelling (dataclasses) |
Finding, CveRecord, PortResult, ScanResult, all config classes |
| REST API integration | NVD API v2 in cve_lookup.py |
| Rate limiting | Token-bucket implementation in cve_lookup.py |
| Caching | TTL disk cache in cve_lookup.py |
Threading (ThreadPoolExecutor) |
network_scanner.py, web_scanner.py |
| CLI design (Typer) | main.py — 7 commands, 20+ flags |
| Rich terminal UI | Progress bars, panels, tables, coloured output throughout |
| HTML/CSS generation | Bootstrap 5 report with Chart.js in report_generator.py |
| PDF generation | WeasyPrint + CSS @page in report_generator.py |
| Network programming | Raw socket banner grabbing in network_scanner.py |
| Security concepts | SQLi, XSS, LFI, CORS, CVSS scoring, CVE data |
| Configuration management | 12-factor env-var config with python-dotenv |
| Error handling | Graceful degradation at every layer, no unhandled exceptions |
| Type hints | Full PEP 526/585 annotations throughout |
-
modules/ssl_auditor.py— TLS certificate and cipher audit (sslyze) -
modules/dns_enum.py— DNS record enumeration and zone transfer -
modules/subdomain_enum.py— Subdomain brute-force + cert transparency -
modules/whois_lookup.py— WHOIS and ASN data -
modules/shodan_lookup.py— Shodan host intelligence - Authenticated web scanning (cookie/token injection)
- Nuclei template integration
- CI/CD GitHub Actions example workflow
- Docker image
ModuScan is for authorized security testing only.
Running this tool against systems you do not own or have explicit written permission to test is illegal in most jurisdictions and may result in criminal prosecution. The author assumes no liability for misuse. Always obtain proper authorization before scanning any target.
Built by Silence · Python · Security · Open Source