Skip to content

Latest commit

 

History

History
359 lines (284 loc) · 16.4 KB

File metadata and controls

359 lines (284 loc) · 16.4 KB

mcp-netscan

An MCP (Model Context Protocol) server that exposes network scanning, HTTP and SSH as tools to a model, with every request bounded by authentication, quotas and target policy. It ships its own scan engine (zscan), so the fastest scan path needs no external binary.

Repository root: /root/pentest-mcp. Workspace resolver 3, Rust edition 2024.

What is in the box

Component Path What it is
mcp-netscan src/, Cargo.toml The MCP server binary: Streamable HTTP transport, auth, quota chain, tool registry.
zscan crates/zscan/ The scan engine library: AF_PACKET TPACKET_V3 rings, probe modules, permutation walk, rate control.
zscan-cli crates/zscan-cli/ The zscan command line binary, plus migrate / user / quota operator subcommands.
quota crates/quota/ Multi-dimensional quota engine: concurrency, cooldown, sliding windows, rate shaping, CPU share.
store crates/store/ Two backends: MongoDB users/secrets/quota profiles plus Redis sessions and the cross-node invalidation bus, or a single JSON file (admin-store.json) for a standalone node. Legacy migration.
admin crates/admin/ axum + askama web panel on its own listener: users, secrets, quota profiles, node status. Served by default; needs no external services.

Tool list. Every built-in tool runs in-process in this binary; only ssh and [[tools.custom]] entries start another program. The per-tool arguments live in docs/TOOLS.md.

Suite Tools Notes
net_scan net_scan Built-in zscan engine and the only scan tool. TCP SYN/ACK/FIN/NULL/XMAS/Maimon/Window, UDP (DNS/mDNS/SNMP/NTP/SSDP/memcached) and ICMP probes, banners, version and OS hints. No external process, so it streams progress and findings.
dns dns_query, dns_bruteforce, dns_zone_transfer, subdomain_permute Resolver queries, wildcard-aware bulk resolution, AXFR, permutation generation.
service redis_check, smtp_enum, ftp_probe, ssh_audit, tls_scan, ike_scan Service probes. redis_check is read-only; smtp_enum and ftp_probe are bounded probes, not crackers.
web_recon http_probe, web_crawl, web_tech, waf_detect, security_headers, cors_check HTTP probing, crawling, fingerprinting, WAF and security-header checks.
web_audit web_fuzz, param_discover, js_endpoints, xss_scan, sqli_scan, ssti_scan, open_redirect, jwt_inspect, cms_detect, graphql_probe Actively probe the target, so each is marked destructive and capped per call.
discovery ping_sweep, nbtscan, snmp_check, snmp_walk, packet_send Host and service discovery. packet_send is the raw AF_PACKET sender.
secrets secret_scan, cewl Credential/high-entropy scanning and site wordlists.
data hash_identify, dedup_lines, json_flatten Local pipeline helpers; no network.
standalone http_request, ssh In-process HTTP client with SSRF guards; ssh runs the system ssh client.
[[tools.custom]] operator-declared binary Config-only plugin tools using the same quota/timeout machinery.

Quickstart

1. Build

cd /root/pentest-mcp
cargo build --release

Products: target/release/mcp-netscan, target/release/zscan.

The server reads config.toml from the working directory, or the path in the MCP_CONFIG environment variable (src/config.rs:833-838).

2. Configure the node

Change at least the credential before starting. In config.toml:

[[auth.secrets]]
name = "default"
token = "CHANGE-ME-default-token"
enabled = true

[node]
iface = "eth0"
# router_mac = "d8:b1:22:68:e1:32"

[node] holds node-local NIC settings. They stay in TOML even when users and quotas live in MongoDB.

3. Run the MCP server

MCP_CONFIG=/root/pentest-mcp/config.toml ./target/release/mcp-netscan

The MCP endpoint is served at server.path (default /mcp). Health and readiness are GET /healthz and GET /readyz; both are open and report status, version, the enabled tool names, and the configured job limits (src/main.rs:407-424).

Point a client at it:

curl -s http://127.0.0.1:8080/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H 'mcp-protocol-version: 2025-06-18' \
  -H 'authorization: Bearer CHANGE-ME-default-token' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

4. Run a scan with the CLI

zscan needs raw sockets:

sudo target/release/zscan 10.0.0.0/24 -p 22,80,443 -i eth0 \
  --router-mac <gw-mac> --adapter-ip <our-ip> \
  --max-rate 10000 --wait 4 --tx-workers 4 \
  --output-format json --output-file hits.jsonl

Engine probe modules come from --probe-module: tcp-synscan (default), tcp-ackscan, tcp-finscan, icmp-echoscan.

Tool reference

net_scan

Native scanner; IPv4 only. Key arguments (src/tools/net_scan.rs:29-231):

Argument Meaning
targets IPs, CIDR blocks, ranges (10.0.0.1-20) or hostnames.
ports 80, 22,80,443, 1-1024, U:53, T:80,U:53.
probe tcp_syn (default), tcp_ack, tcp_fin, tcp_null, tcp_xmas, tcp_maimon, tcp_window; udp_dns, udp_mdns, udp_snmp, udp_ntp, udp_ssdp, udp_memcached, udp_empty; icmp_echo, icmp_timestamp, icmp_address_mask; ip_proto.
dnsName UDP payload name for udp_dns.
numProbes Probe copies per host/port.
ratePps Requested packet rate; clamped by every quota layer.
shard "n/N" slice for distributed scanning.
exclude CIDR list of addresses to skip.
banners Complete TCP handshakes and read service banners.
bannerHello Bytes pushed after connect, e.g. GET / HTTP/1.0\r\n\r\n.
seed Traversal-order seed, deterministic across shards.

The effective packet rate is the minimum of the tool ceiling ([tools.net_scan].max_rate_pps), [node].max_pps, the caller's TOML quota, the cluster profile and ratePps (src/tools/net_scan.rs:528-553); the byte-rate chain adds [node].max_bytes_per_sec (src/tools/net_scan.rs:554-563). Findings are capped by [tools.net_scan].max_findings (src/tools/net_scan.rs:899).

The argument struct is larger than this table: the engine also accepts the nmap/zmap/masscan-compatible port-selection, pacing, service/version, output and file-input flags (src/tools/net_scan.rs:64-231). randomizeHosts is accepted for compatibility only — the engine already walks targets in seed-derived pseudo-random order (src/tools/net_scan.rs:208-214).

http_request

Any method from allowed_methods, arbitrary headers, body, query parameters, redirect control. Guards: deny_metadata_endpoints blocks 169.254.169.254, 100.100.100.200 and fd00:ec2::254; validate_resolved_addresses checks every resolved A/AAAA record before connecting (src/tools/http.rs:341-395, src/targets.rs:395).

ssh

Host or argv command, stdin, key/agent/password auth, allowed_hosts globs and allowed_cidrs checks, host_key_checking policy. Passwords go through an ephemeral SSH_ASKPASS helper and never appear in argv (src/tools/ssh.rs:1-8).

Running the operator subcommands

All three share --mongo-uri (env ZSCAN_MONGO_URI), --mongo-db (default zscan) and --redis-uri (env ZSCAN_REDIS_URI).

zscan migrate

Imports legacy config.toml [[auth.secrets]] entries into MongoDB. Idempotent; already-migrated names are skipped.

zscan migrate --config config.toml \
  --mongo-uri mongodb://127.0.0.1:27017 \
  --redis-uri redis://127.0.0.1:6379

Output: imported secret '<name>' (token + quota profile) per secret, then <N> secret(s) imported; nothing to import (already migrated) when there is nothing to do (crates/zscan-cli/src/main.rs:705-712).

zscan user add|list

zscan user add alice --password-stdin --role admin \
  --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379
zscan user list --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379

--role is one of admin, operator, viewer (default viewer). This subcommand requires a password of at least 10 characters (crates/zscan-cli/src/main.rs:729-730); the hash is argon2id everywhere (crates/store/src/users.rs:47-61). add fails when the name is taken (Mongo: crates/store/src/users.rs:100-105; file backend: crates/store/src/local.rs:204-206).

zscan quota set|list

zscan quota set alice --concurrency 4 --rpm 60 --rpd 500 --max-pps 10000 \
  --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379
zscan quota list --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379

Flags: --concurrency (default 4), --cooldown-secs, --priority, --rpm, --rph, --rpd, --rpw, --max-pps, --cpu-pct. Zero means "dimension unset". A profile with subject alice covers every tool; subject alice:net_scan overrides it for one tool (src/server.rs:152-158).

Admin panel

The panel is served by default ([store].admin_enabled = true, src/config.rs:88) on its own listener, so its cookies never mix with MCP transport auth. It listens on [store].admin_bind (default 127.0.0.1:9100, src/config.rs:89). Keep it on loopback and terminate TLS in front of it.

It needs no external services. Without [store].mongo_uri the server opens a file-backed store at [store].local_path (default admin-store.json, src/config.rs:66, src/config.rs:90). That file holds users (argon2id password hashes), secrets (argon2 hashes) and quota profiles; it is written atomically with 0600 permissions and is gitignored (crates/store/src/local.rs:1-17, opened by connect_shared_state at src/main.rs:225-243). Sessions live in memory only and the invalidation bus is a no-op, because there is no other node to tell. Point [store].mongo_uri and [store].redis_uri at real services for the multi-node backend (crates/store/src/lib.rs:36-57).

Bootstrap flow

  1. With no users, GET /admin/login shows the first-run form (crates/admin/templates/login.html).
  2. POST /admin/bootstrap with username and password creates the first account with role admin and redirects to /admin/login.
  3. Once any user exists, the same POST returns 403 bootstrap closed: users already exist (crates/admin/src/handlers.rs:38-44). There is no re-open path in code.
curl -i -X POST http://127.0.0.1:9100/admin/bootstrap \
  -d 'username=admin&password=<strong-password>'

Roles: admin may manage users and quota profiles; any signed-in user may see the dashboard and their own secrets and mint new ones (crates/admin/src/handlers.rs:27-33, crates/admin/src/handlers.rs:123-133).

Configuration overview

config.toml is parsed into Config with deny_unknown_fields, so a typo is a startup error. Config::validate rejects nonsensical combinations and the process exits with code 2 on a configuration error (src/main.rs:37-43).

[server]

Key Default Effect
host, port 0.0.0.0, 8080 MCP listener.
path /mcp Mount path, normalized to a leading slash and no trailing slash.
public_base_url unset Informational, for logs.
allowed_hosts localhost set Host header allow-list (DNS-rebinding guard). Empty disables the check.
allowed_origins [] Origin allow-list. Empty disables the check.
session_mode false false is stateless, best behind a load balancer.
json_response true JSON instead of SSE for simple calls.
max_request_body_bytes 1 MiB Body ceiling, checked on every route.
sse_keep_alive_secs, tcp_keepalive_secs 15, 60 Keepalive tuning.
graceful_shutdown_secs 20 Configured but not read by the runtime (see "Known gaps").

[auth]

Key Default Effect
enabled true When false every caller is anonymous.
header authorization Header inspected. authorization strips a Bearer prefix.
require_secret true Reject unknown credentials.
max_args_bytes 65536 Configured but not enforced anywhere (see "Known gaps").
[[auth.secrets]] one example Credential list, each with name, token, allow_tools, deny_tools, enabled, [auth.secrets.quota].

[limits]

Global ceilings applied to every job regardless of credential: max_concurrent_jobs (8), max_job_runtime_secs (900), max_output_bytes (2 MiB), max_stream_bytes (2 MiB), default_requests_per_minute (60, configured but unused), rate_limit_penalty_secs (60), max_tool_call_secs (900), job_retention_secs (3600, configured but unused).

[node]

Node-local scan settings: iface, router_mac, adapter_mac, adapter_ip, max_pps, max_bytes_per_sec. net_scan and the discovery suite clamp their packet rate to max_pps and net_scan clamps its byte rate to max_bytes_per_sec (src/tools/net_scan.rs:535-563, src/tools/discovery_suite.rs:465-484). adapter_mac is still declared but not read by any tool (see "Known gaps").

[store]

mongo_uri, mongo_db (default zscan), redis_uri, cookie_key (128 hex characters), session_ttl_secs (86400), admin_bind, cluster_queue_wait_secs (30), admin_enabled (true) and local_path (default admin-store.json) (src/config.rs:51-93). Leaving the two URIs unset keeps the node standalone: TOML credentials for MCP calls, and a file-backed store for the admin panel and cluster quota profiles (no cross-node sync; src/main.rs:225-243, src/main.rs:112-125).

[tools.*]

One section per suite or standalone tool: [tools.net_scan], [tools.http_request], [tools.ssh], [tools.data], [tools.dns], [tools.secrets], [tools.service], [tools.web_recon], [tools.web_audit] and [tools.discovery], plus [[tools.custom]] entries. net_scan, http_request and ssh each have their own schema; the other suites carry a [tools.<suite>.limits] envelope with enabled, timeout_secs, max_output_bytes, max_concurrency, max_targets and max_requests_per_sec (0 = no cap) (src/tools/native.rs:27-44). A limit can only tighten a call: an oversized target list is refused before any traffic, concurrency is clamped, and max_requests_per_sec only paces (src/tools/native.rs:59-89). Each suite may add keys under [tools.<suite>], for example [tools.service.smtp] or [tools.web_audit].max_requests_per_call.

Add a config-only tool without recompiling:

[[tools.custom]]
enabled = true
name = "dns_lookup"
description = "Resolve a hostname with dig."
binary = "/usr/bin/dig"
args = ["+short", "{{name}}"]
timeout_secs = 10
max_output_bytes = 65536
allowed_values = ["name:example.com", "name:example.org"]

Documentation map

File Contents
docs/README.md This file: what the project is, quickstart, tools, config.
docs/TOOLS.md Per-tool reference: arguments, limits and examples.
docs/ARCHITECTURE.md Module map, MCP request flow, engine internals, quota layers.
docs/MIGRATION.md Moving from shell-backed scanners to net_scan; TOML secrets to MongoDB.
docs/THREAT_MODEL.md Assets, actors, trust boundaries, abuse paths, known gaps.
docs/PERFORMANCE.md Measured zscan vs zmap vs masscan numbers and method.

Known gaps in the shipped configuration

These are real and visible in code; see docs/THREAT_MODEL.md for the security consequences.

  • auth.max_args_bytes (src/config.rs:241), limits.default_requests_per_minute (src/config.rs:202), limits.job_retention_secs (src/config.rs:208), server.graceful_shutdown_secs (src/config.rs:115), tools.ssh.max_concurrent_sessions (src/config.rs:674) and tools.ssh.strict_host_key_checking_known_hosts (src/config.rs:666) are parsed but never read outside src/config.rs.
  • [node].adapter_mac is declared but not read by any tool (src/config.rs:40). [node].max_pps is read now: net_scan and the discovery suite clamp their packet rate to it (src/tools/net_scan.rs:537, src/tools/discovery_suite.rs:467), and net_scan clamps its byte rate to [node].max_bytes_per_sec (src/tools/net_scan.rs:555).
  • tools.net_scan.max_port_count has no effect: the tool passes 0 (src/tools/net_scan.rs:435).
  • The MCP authenticator only compares against [[auth.secrets]] in TOML. MongoDB-backed tokens authenticate through Store::authenticate_token but the MCP data path never calls it (src/auth.rs:123-171).