Repository navigation
Add minimal Python MCP recon server with fixed nmap/curl/whois tools - #52
Conversation
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
There was a problem hiding this comment.
Code Review
This pull request implements a minimal MCP Python PenTest Server providing tools for port scanning, HTTP header inspection, and WHOIS lookups. The server includes target validation and subprocess management. Feedback highlights the need to redact credentials from logs for security, remove unreachable null checks in the target validation, and update the setup script to fail if required binaries are missing.
| if target is None: | ||
| return "target is required" |
There was a problem hiding this comment.
:::writing{variant=“standard” id=“18452”}
Good catch. Since target is declared as str, None is not a valid value, and MCP already enforces required arguments at the tool boundary. That makes the target is None branch unreachable in normal execution, so I’ll remove it.
If we want defensive handling for malformed callers outside MCP, the better approach would be to widen the signature explicitly, for example target: str | None, and then validate inside the function. As written, the current check is redundant.
:::
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a standalone, local Python MCP server (“pentest-mcp-v1”) that exposes a fixed, minimal recon tool surface (nmap/curl/whois) via controlled subprocess execution, plus bootstrap/docs to run it.
Changes:
- Added
server.pyimplementing an MCP server withnmap_scan,curl_head, andwhois_lookup, including timeout + output truncation and structured results. - Added
setup.shto install the Pythonmcpdependency and check for required binaries. - Added
README_PENTEST.mddocumenting tool surface, output contract, and basic usage.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| setup.sh | Bootstraps Python dependency installation and checks for nmap, curl, whois. |
| server.py | Implements FastMCP server + subprocess runner with validation, timeout, and truncation. |
| README_PENTEST.md | Documents the tool surface, result contract, and how to run via stdio MCP clients. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| redacted_cmd_str = re.sub(r"(://[^:]+:)[^@]+@", r"\1<redacted>@", cmd_str) | ||
| logger.info("executing command=%s", redacted_cmd_str) |
There was a problem hiding this comment.
Logging the fully rendered command (cmd_str) can leak secrets if the target is a URL containing credentials or tokens (e.g., https://user:pass@host or query params). Either redact sensitive URL components before logging, or log only the tool name plus a minimally identifying target (e.g., host) to avoid credential exposure.
| try: | ||
| completed = subprocess.run( | ||
| command, | ||
| capture_output=True, | ||
| text=True, | ||
| timeout=TIMEOUT_SECONDS, | ||
| check=False, |
There was a problem hiding this comment.
subprocess.run(..., capture_output=True) buffers the full stdout/stderr in memory and truncates only after completion, so a noisy command can still cause high memory usage before truncation kicks in. If you want a hard output cap, consider streaming reads with a byte/char limit (or redirecting to a temp file) so the process can’t exceed MAX_OUTPUT_CHARS in memory.
| echo "python3 is required" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
There was a problem hiding this comment.
setup.sh assumes python3 -m pip exists and installs packages into whatever environment python3 points to (often the system interpreter), which can fail without permissions and can pollute global site-packages. Consider checking for pip explicitly and guiding users to create/activate a venv (or use pipx) before installing mcp.
| # Ensure python3 has pip available | |
| if ! python3 -m pip --version >/dev/null 2>&1; then | |
| echo "python3 pip module is required but was not found." >&2 | |
| echo "Please install pip for python3 (for example, using 'python3 -m ensurepip --upgrade'" >&2 | |
| echo "or your OS package manager), then re-run this script." >&2 | |
| exit 1 | |
| fi | |
| # Require a virtual environment to avoid installing into the system Python | |
| in_venv="$(python3 - <<'PY' | |
| import sys | |
| in_venv = getattr(sys, "base_prefix", sys.prefix) != sys.prefix | |
| print("1" if in_venv else "0") | |
| PY | |
| )" | |
| if [ "$in_venv" != "1" ]; then | |
| cat >&2 <<'EOF' | |
| This script should be run inside a Python virtual environment to avoid | |
| installing packages into the system Python. | |
| To create and activate a virtual environment, you can run: | |
| python3 -m venv .venv | |
| . .venv/bin/activate | |
| Then re-run this script. | |
| Alternatively, you can install "mcp" using pipx: | |
| pipx install "mcp>=1.0.0" | |
| EOF | |
| exit 1 | |
| fi |
| @mcp.tool( | ||
| name="nmap_scan", | ||
| description="Run fast port discovery with fixed pattern: nmap -F <target>", | ||
| ) | ||
| def nmap_scan(target: str) -> ToolResult: |
There was a problem hiding this comment.
Because _TARGET_PATTERN permits "/", nmap_scan currently accepts CIDR/range-style targets (e.g., 10.0.0.0/8), which can generate a large amount of traffic before the timeout triggers. If the intent is “basic recon” on a single host, consider tightening validation for nmap/whois to disallow CIDR/ranges/paths and require a hostname/IP only.
| candidate = target.strip() | ||
| if not candidate: | ||
| return "target cannot be empty" | ||
| if len(candidate) > 254: | ||
| return "target is too long" |
There was a problem hiding this comment.
_validate_target() strips the target into candidate, but the sanitized value is never returned/used. This means inputs like "example.com " can pass validation and still be executed with the original (whitespace-containing) argument, which is inconsistent with the safety guarantees. Consider returning the sanitized target (or a tuple of (sanitized, error)) and building the command from that value.
| # Allows domains, IPv4, IPv6, hostnames, and optional URL-ish punctuation used by curl. | ||
| _TARGET_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/?&=%#@+\-\[\]]{0,253}$") | ||
|
|
There was a problem hiding this comment.
The shared _TARGET_PATTERN allows URL schemes/paths (":" and "/"), which lets curl_head accept file://... and other non-HTTP(S) schemes. That can exfiltrate local files from the host running the server, contradicting the “remote recon” safety intent. Recommend tool-specific validation for curl that only permits http:// and https:// (and rejects file:, scp:, etc.).
📝 WalkthroughWalkthroughThis PR introduces a new MCP (Model Context Protocol) server implementation for penetration testing tools. It includes documentation defining the server contract, a Python FastMCP server exposing three recon tools ( Changes
Sequence DiagramsequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
participant Subprocess as Subprocess<br/>(nmap/curl/whois)
Client->>Server: Call tool (e.g., nmap_scan)<br/>with target parameter
Server->>Server: Validate target<br/>(non-empty, pattern match,<br/>length limits)
alt Validation fails
Server-->>Client: Return ToolResult<br/>(ok=false, error details)
else Validation passes
Server->>Server: Build command<br/>(fixed pattern +<br/>validated target)
Server->>Subprocess: Execute with<br/>timeout & capture output
alt Subprocess succeeds
Subprocess-->>Server: stdout, stderr,<br/>exit code
else Timeout occurs
Server->>Server: Capture partial output<br/>+ timeout message
else Binary missing
Server->>Server: Set exit code 127
end
Server->>Server: Truncate output<br/>(if > MAX_OUTPUT_CHARS)
Server->>Server: Log command
Server-->>Client: Return ToolResult<br/>(ok, command, exit_code,<br/>stdout, stderr, truncated)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server.py`:
- Around line 58-70: Validation currently strips the input inside
_validate_target but the caller (_run_tool) continues to pass the original
unstripped target to the subprocess, letting inputs like " example.com " bypass
the README rule; fix by normalizing once and using the normalized value
downstream: either change _validate_target to return the cleaned/stripped string
(or raise/return an error) so callers can use that returned value, or ensure
_run_tool explicitly does before calling
_validate_target and before launching the subprocess; update references to
_validate_target and any subprocess invocation in _run_tool to use the stripped
value.
- Around line 149-154: The curl_head tool currently invokes curl in curl_head
and accepts non-HTTP protocols; modify the argument list passed to _run_tool in
curl_head to include the flag --proto '=http,https' so curl is restricted to
only HTTP/HTTPS, and update the curl_head description if needed to reflect the
enforced protocol restriction; target the curl_head function and the call to
_run_tool for this change.
- Around line 115-117: The TimeoutExpired handler is discarding partial output
because it checks for str; update the except block that sets partial_stdout and
partial_stderr (the subprocess.TimeoutExpired handler) to accept bytes by
handling bytes or str: if exc.stdout/exc.stderr are bytes, decode them (e.g.,
using .decode('utf-8', errors='replace')), if they are already str use as-is,
and default to empty string when None; ensure you apply the same logic to both
partial_stdout and partial_stderr so partial output is preserved for logging or
reporting.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e3f9b0f4-aa4e-4f1d-8eca-056169a64be1
📒 Files selected for processing (3)
README_PENTEST.mdserver.pysetup.sh
| def _validate_target(target: str) -> str | None: | ||
| if target is None: | ||
| return "target is required" | ||
| candidate = target.strip() | ||
| if not candidate: | ||
| return "target cannot be empty" | ||
| if len(candidate) > 254: | ||
| return "target is too long" | ||
| if " " in candidate or "\t" in candidate or "\n" in candidate: | ||
| return "target cannot contain whitespace" | ||
| if not _TARGET_PATTERN.match(candidate): | ||
| return "target contains unsupported characters" | ||
| return None |
There was a problem hiding this comment.
Don't strip for validation unless you also execute the stripped value.
Line 61 normalizes into candidate, but _run_tool() still passes the raw target to the subprocess. " example.com " therefore clears validation even though README_PENTEST.md Line 31 says whitespace is rejected.
Suggested fix
def _validate_target(target: str) -> str | None:
if target is None:
return "target is required"
- candidate = target.strip()
- if not candidate:
+ if not target.strip():
return "target cannot be empty"
+ if target != target.strip():
+ return "target cannot contain leading or trailing whitespace"
+ candidate = target
if len(candidate) > 254:
return "target is too long"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server.py` around lines 58 - 70, Validation currently strips the input inside
_validate_target but the caller (_run_tool) continues to pass the original
unstripped target to the subprocess, letting inputs like " example.com " bypass
the README rule; fix by normalizing once and using the normalized value
downstream: either change _validate_target to return the cleaned/stripped string
(or raise/return an error) so callers can use that returned value, or ensure
_run_tool explicitly does before calling
_validate_target and before launching the subprocess; update references to
_validate_target and any subprocess invocation in _run_tool to use the stripped
value.
| except subprocess.TimeoutExpired as exc: | ||
| partial_stdout = exc.stdout if isinstance(exc.stdout, str) else "" | ||
| partial_stderr = exc.stderr if isinstance(exc.stderr, str) else "" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
import subprocess, sys
cmd = [
sys.executable,
"-c",
"import sys,time; print('out'); print('err', file=sys.stderr); sys.stdout.flush(); sys.stderr.flush(); time.sleep(1)",
]
try:
subprocess.run(cmd, capture_output=True, text=True, timeout=0.1)
except subprocess.TimeoutExpired as exc:
print(type(exc.stdout).__name__, repr(exc.stdout))
print(type(exc.stderr).__name__, repr(exc.stderr))
PYRepository: canstralian/promptcrafting-mcp
Length of output: 1789
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
import subprocess, sys
cmd = [
sys.executable,
"-c",
"import sys,time; print('out'); print('err', file=sys.stderr); sys.stdout.flush(); sys.stderr.flush(); time.sleep(1)",
]
try:
subprocess.run(cmd, capture_output=True, text=True, timeout=0.1)
except subprocess.TimeoutExpired as exc:
print(type(exc.stdout).__name__, repr(exc.stdout))
print(type(exc.stderr).__name__, repr(exc.stderr))
PYRepository: canstralian/promptcrafting-mcp
Length of output: 1874
🏁 Script executed:
# Find and examine server.py
find . -name "server.py" -type fRepository: canstralian/promptcrafting-mcp
Length of output: 1874
🌐 Web query:
Python subprocess TimeoutExpired stdout stderr text=True bytes or str
💡 Result:
In Python's subprocess module, when using subprocess.run with text=True (or encoding specified) and a timeout that expires, the resulting subprocess.TimeoutExpired exception has stdout, stderr, and output attributes that are bytes (or None), not str as expected for text mode. This is the documented and current behavior as of Python 3.13 (docs.python.org/3.13/library/subprocess.html): "This is always bytes when any output was captured regardless of the text=True setting." To handle this and get strings, manually decode after catching the exception: try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) except subprocess.TimeoutExpired as e: stdout = e.stdout.decode('utf-8', errors='replace') if e.stdout is not None else '' stderr = e.stderr.decode('utf-8', errors='replace') if e.stderr is not None else '' print(f"Timeout. stdout: {stdout}, stderr: {stderr}") Note: Decoding may fail if output is incomplete (e.g., partial UTF-8 sequences), hence using errors='replace'. This behavior is unchanged in recent Python versions (3.13, 3.14, 3.15 docs) and is a known issue (github.com/python/cpython/issues/87597) with an open PR (#95579) to fix it, but not yet merged due to API compatibility concerns.
Citations:
- 1: https://docs.python.org/3.12/library/subprocess.html
- 2: https://docs.python.org/3/library/subprocess.html
- 3: https://docs.python.org/3.15/library/subprocess.html
- 4: https://docs.python.org/3.13/library/subprocess.html
- 5: gh-87597: Decode subprocess output in text mode when timeout is hit python/cpython#95579
- 6: Subprocess timeout causes output to be returned as bytes in text mode python/cpython#87597
- 7: https://bugs.python.org/issue43431
- 8: https://stackoverflow.com/questions/64640506/capture-output-from-subprocess-with-a-timeout-set
- 9: https://runebook.dev/en/docs/python/library/subprocess/subprocess.TimeoutExpired.output
TimeoutExpired stdout/stderr are bytes, not str—partial output is lost.
Python's subprocess.TimeoutExpired always exposes stdout/stderr as bytes (even with text=True), per official documentation. Lines 116-117's isinstance(exc.stdout, str) check will always fail, returning empty strings instead of partial output.
Suggested fix
except subprocess.TimeoutExpired as exc:
- partial_stdout = exc.stdout if isinstance(exc.stdout, str) else ""
- partial_stderr = exc.stderr if isinstance(exc.stderr, str) else ""
+ partial_stdout = (
+ exc.stdout.decode(errors="replace")
+ if isinstance(exc.stdout, bytes)
+ else (exc.stdout or "")
+ )
+ partial_stderr = (
+ exc.stderr.decode(errors="replace")
+ if isinstance(exc.stderr, bytes)
+ else (exc.stderr or "")
+ )
stdout, stdout_truncated = _truncate(partial_stdout)
stderr, stderr_truncated = _truncate(partial_stderr)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server.py` around lines 115 - 117, The TimeoutExpired handler is discarding
partial output because it checks for str; update the except block that sets
partial_stdout and partial_stderr (the subprocess.TimeoutExpired handler) to
accept bytes by handling bytes or str: if exc.stdout/exc.stderr are bytes,
decode them (e.g., using .decode('utf-8', errors='replace')), if they are
already str use as-is, and default to empty string when None; ensure you apply
the same logic to both partial_stdout and partial_stderr so partial output is
preserved for logging or reporting.
| @mcp.tool( | ||
| name="curl_head", | ||
| description="Run HTTP header inspection with fixed pattern: curl -I <target>", | ||
| ) | ||
| def curl_head(target: str) -> ToolResult: | ||
| return _run_tool(["curl", "-I", target], target) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
command -v curl >/dev/null
printf 'Current behavior:\n'
curl -I file:///etc/hosts 2>&1 | sed -n '1,5p' || true
printf '\nRestricted behavior:\n'
curl --proto '=http,https' -I file:///etc/hosts 2>&1 | sed -n '1,5p' || trueRepository: canstralian/promptcrafting-mcp
Length of output: 1789
🌐 Web query:
curl --proto flag http https protocol restriction documentation
💡 Result:
The --proto option in curl restricts the protocols (URL schemes) that curl is allowed to use in a transfer. It takes a comma-separated list of protocols, such as --proto '=http,https' to allow only HTTP and HTTPS. Use + prefix to enable specific protocols (add to default), / to disable specific ones (remove from default), or = to set exactly those (replaces default). By default, curl allows all protocols it supports. This is useful for security, to prevent use of unintended protocols like file:// or ftp://. Related options: - --proto-redir: Restricts protocols for redirects (when -L/--location is used). - --proto-default: Sets default protocol when no scheme is provided in URL. Examples: curl --proto '=http,https' https://example.com # Only HTTP/HTTPS allowed curl --proto '+https' http://example.com # Adds HTTPS to allowed list This corresponds to libcurl's CURLOPT_PROTOCOLS option.
Citations:
- 1: https://curl.se/docs/manpage.html
- 2: https://curl.haxx.se/libcurl/c/CURLOPT_PROTOCOLS.html
- 3: https://github.com/jbranchaud/til/blob/master/unix/limit-protocols-used-in-a-curl-command.md
- 4: https://curl.se/docs/manpage.html?category=services
- 5: https://curl.haxx.se/libcurl/c/CURLOPT_REDIR_PROTOCOLS.html
🌐 Web query:
curl -I file:// ftp:// protocol handling default behavior
💡 Result:
The default behavior of curl -I (or --head) for file:// and ftp:// protocols is to fetch headers only, without transferring the file body. This displays file metadata like size and last modification time if the file exists, or returns an error if it does not. For file:// URLs (local files): - curl stats the file and generates HTTP-like headers: HTTP/1.1 200 OK, Content-Length: , Last-Modified: , Accept-Ranges: bytes. - Works for files on Unix-like systems; directories may fail on Windows (error 37: Couldn't open file). - Implemented in libcurl's lib/file.c, which sends headers without reading the body when no_body is set (via -I). For ftp:// URLs (FTP servers): - curl sends FTP SIZE and MDTM commands to get file size and modification time, then outputs similar HTTP-like headers. - Errors if the file does not exist (e.g., 550). - Anonymous login uses anonymous: ftp@example.com if no credentials provided. - Confirmed for FTPS with --ssl --head. Examples: - curl -I file:///path/to/existingfile.txt → Headers with size/mtime. - curl -I ftp://example.com/file.txt → Headers if exists, error otherwise. This is the standard "HEAD" equivalent for non-HTTP protocols, as documented in curl man pages and official resources. Note: file:// does not support remote access; only local files.
Citations:
- 1: https://curl.se/docs/manpage.html
- 2: https://man7.org/linux/man-pages/man1/curl.1.html
- 3: https://stackoverflow.com/questions/5951649/check-if-file-exists-on-ftps-site-using-curl
- 4: https://curl.se/libcurl/c/CURLOPT_NOBODY.html
- 5: https://github.com/curl/curl/blob/master/lib/file.c
- 6: https://www.man7.org/linux/man-pages/man1/curl.1.html
- 7: https://man.cx/curl(1)
curl_head accepts all protocols, not just HTTP(S).
The description claims "HTTP header inspection," but curl -I by default supports file://, ftp://, ftps://, and other protocols beyond HTTP and HTTPS. This allows the tool to access local files or non-HTTP resources from the host, contradicting its stated purpose.
Add --proto '=http,https' to restrict the tool to only HTTP and HTTPS:
Suggested fix
def curl_head(target: str) -> ToolResult:
- return _run_tool(["curl", "-I", target], target)
+ return _run_tool(["curl", "--proto", "=http,https", "-I", target], target)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @mcp.tool( | |
| name="curl_head", | |
| description="Run HTTP header inspection with fixed pattern: curl -I <target>", | |
| ) | |
| def curl_head(target: str) -> ToolResult: | |
| return _run_tool(["curl", "-I", target], target) | |
| `@mcp.tool`( | |
| name="curl_head", | |
| description="Run HTTP header inspection with fixed pattern: curl -I <target>", | |
| ) | |
| def curl_head(target: str) -> ToolResult: | |
| return _run_tool(["curl", "--proto", "=http,https", "-I", target], target) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server.py` around lines 149 - 154, The curl_head tool currently invokes curl
in curl_head and accepts non-HTTP protocols; modify the argument list passed to
_run_tool in curl_head to include the flag --proto '=http,https' so curl is
restricted to only HTTP/HTTPS, and update the curl_head description if needed to
reflect the enforced protocol restriction; target the curl_head function and the
call to _run_tool for this change.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
promptcrafting-mcp | 62d2c5b | Mar 29 2026, 06:57 AM |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fi | ||
|
|
||
| python3 -m pip install --upgrade pip | ||
| python3 -m pip install "mcp>=1.0.0" |
There was a problem hiding this comment.
setup.sh installs mcp>=1.0.0, but server.py imports mcp.server.fastmcp.FastMCP (added in mcp v1.2.0). As-is, a fresh setup can succeed but the server will fail at import time. Bump the minimum to a version that includes FastMCP (e.g. mcp>=1.2.0) and keep README/setup consistent.
| python3 -m pip install "mcp>=1.0.0" | |
| python3 -m pip install "mcp>=1.2.0" |
| python3 -m pip install --upgrade pip | ||
| python3 -m pip install "mcp>=1.0.0" | ||
|
|
There was a problem hiding this comment.
This script uses python3 -m pip install ... into the system environment. On many distros with PEP 668 “externally managed” Python, this fails unless the user creates a venv or uses pipx/--user. Consider updating setup.sh (and README) to create/activate a virtualenv and install into it, or document the supported installation method explicitly.
| python3 -m pip install --upgrade pip | |
| python3 -m pip install "mcp>=1.0.0" | |
| VENV_DIR="${VENV_DIR:-.venv}" | |
| if [ ! -d "$VENV_DIR" ]; then | |
| python3 -m venv "$VENV_DIR" | |
| fi | |
| # shellcheck disable=SC1090 | |
| . "$VENV_DIR/bin/activate" | |
| pip install --upgrade pip | |
| pip install "mcp>=1.0.0" |
| LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() | ||
| TIMEOUT_SECONDS = int(os.getenv("TOOL_TIMEOUT_SECONDS", "20")) | ||
| MAX_OUTPUT_CHARS = int(os.getenv("MAX_OUTPUT_CHARS", "20000")) |
There was a problem hiding this comment.
TIMEOUT_SECONDS / MAX_OUTPUT_CHARS are parsed with int(os.getenv(...)) at import time. If the env vars are set to non-integer values, the module import will raise ValueError and crash the server (contradicting the “failure containment” goal). Consider defensive parsing with defaults/clamping so the server can still start with a structured/logged error.
| redacted_cmd_str = re.sub(r"(://[^:]+:)[^@]+@", r"\1<redacted>@", cmd_str) | ||
| logger.info("executing command=%s", redacted_cmd_str) | ||
|
|
||
| try: | ||
| completed = subprocess.run( | ||
| command, | ||
| capture_output=True, | ||
| text=True, | ||
| timeout=TIMEOUT_SECONDS, | ||
| check=False, | ||
| ) | ||
| stdout, stdout_truncated = _truncate(completed.stdout) | ||
| stderr, stderr_truncated = _truncate(completed.stderr) | ||
| return { | ||
| "ok": completed.returncode == 0, | ||
| "command": cmd_str, | ||
| "exit_code": completed.returncode, | ||
| "stdout": stdout, | ||
| "stderr": stderr, | ||
| "truncated": stdout_truncated or stderr_truncated, | ||
| } |
There was a problem hiding this comment.
cmd_str is redacted for logging, but the returned ToolResult includes the unredacted command. Since targets may include URL userinfo (e.g. http://user:pass@host), this can leak credentials back to the MCP client/logs downstream. Consider returning the redacted command string (or separately returning both raw+redacted, with the raw omitted by default).
| # Allows domains, IPv4, IPv6, hostnames, and optional URL-ish punctuation used by curl. | ||
| _TARGET_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/?&=%#@+\-\[\]]{0,253}$") | ||
|
|
||
|
|
||
| def _invalid_target_result(command: str, reason: str) -> ToolResult: | ||
| return { | ||
| "ok": False, | ||
| "command": command, | ||
| "exit_code": 2, | ||
| "stdout": "", | ||
| "stderr": f"invalid target: {reason}", | ||
| "truncated": False, | ||
| } | ||
|
|
||
|
|
||
| def _validate_target(target: str) -> str | None: | ||
| if target is None: | ||
| return "target is required" | ||
| candidate = target.strip() | ||
| if not candidate: | ||
| return "target cannot be empty" | ||
| if len(candidate) > 254: | ||
| return "target is too long" | ||
| if " " in candidate or "\t" in candidate or "\n" in candidate: | ||
| return "target cannot contain whitespace" | ||
| if not _TARGET_PATTERN.match(candidate): | ||
| return "target contains unsupported characters" |
There was a problem hiding this comment.
The current target validation permits arbitrary schemes/paths (e.g. file:///..., gopher://...) and localhost/private IPs. With curl -I <target>, that enables local file probing and SSRF to local/internal services, which conflicts with the stated goal of “basic remote recon” and “strict safety”. Consider restricting curl_head targets to http:///https:// (or host[:port] only) and optionally blocking loopback/link-local/private ranges.
Motivation
Description
server.pywhich registers an MCP namedpentest-mcp-v1and exposes three tools:nmap_scan,curl_head, andwhois_lookup, each mapped to a fixed command pattern._run_toolthat performs strict target validation, logs executed commands, enforcesTOOL_TIMEOUT_SECONDSandMAX_OUTPUT_CHARS, truncates large outputs, and returns the structuredToolResultJSON contract (ok,command,exit_code,stdout,stderr,truncated).127, timeout →124, validation failures →exit_code2, unexpected exceptions →1) and avoid crashing the server on tool errors.setup.shto bootstrap themcpdependency and check fornmap,curl, andwhois, and addREADME_PENTEST.mddocumenting the tool surface, output contract, safety guarantees, and example MCP client invocation.Testing
python3 -m py_compile server.py, which succeeded.bash -n setup.sh, which succeeded.nmap,curl, andwhoisinstalled.Codex Task
Summary by CodeRabbit
Release Notes
New Features
Documentation
Chores