mcpnuke 6.22.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcpnuke/__init__.py +14 -0
- mcpnuke/__main__.py +272 -0
- mcpnuke/_docsgen.py +297 -0
- mcpnuke/_runner_entry.py +55 -0
- mcpnuke/checks/__init__.py +859 -0
- mcpnuke/checks/_lane_helpers.py +65 -0
- mcpnuke/checks/actuator_probe.py +229 -0
- mcpnuke/checks/agentic_loop.py +176 -0
- mcpnuke/checks/ai_guardrail_probe.py +143 -0
- mcpnuke/checks/anon_budget_exhaust.py +182 -0
- mcpnuke/checks/base.py +102 -0
- mcpnuke/checks/behavioral.py +446 -0
- mcpnuke/checks/chaining.py +345 -0
- mcpnuke/checks/command_injection_broad.py +213 -0
- mcpnuke/checks/config_dump.py +85 -0
- mcpnuke/checks/config_tampering.py +79 -0
- mcpnuke/checks/credential_in_schema.py +40 -0
- mcpnuke/checks/dpop_enforcement.py +263 -0
- mcpnuke/checks/error_leakage.py +120 -0
- mcpnuke/checks/execution.py +90 -0
- mcpnuke/checks/exfil_flow.py +225 -0
- mcpnuke/checks/inference_backend.py +757 -0
- mcpnuke/checks/injection.py +297 -0
- mcpnuke/checks/input_sanitization.py +290 -0
- mcpnuke/checks/insecure_agent_comms.py +97 -0
- mcpnuke/checks/jwt_boundary.py +245 -0
- mcpnuke/checks/jwt_validation.py +220 -0
- mcpnuke/checks/k8s_chain_probe.py +69 -0
- mcpnuke/checks/list_cache.py +135 -0
- mcpnuke/checks/llm_analysis.py +865 -0
- mcpnuke/checks/model_routing.py +111 -0
- mcpnuke/checks/permissions.py +109 -0
- mcpnuke/checks/prompt_injection_t01.py +188 -0
- mcpnuke/checks/prompt_leakage.py +33 -0
- mcpnuke/checks/protected_resource_metadata.py +229 -0
- mcpnuke/checks/rate_limit.py +89 -0
- mcpnuke/checks/remote_package_exec.py +140 -0
- mcpnuke/checks/resource_poisoning.py +121 -0
- mcpnuke/checks/response_credentials.py +80 -0
- mcpnuke/checks/routing_header_binding.py +100 -0
- mcpnuke/checks/schema_overdisclosure.py +147 -0
- mcpnuke/checks/scope_pollution.py +164 -0
- mcpnuke/checks/sdk_cache_tamper.py +338 -0
- mcpnuke/checks/shadow_mcp_server.py +57 -0
- mcpnuke/checks/shell_injection.py +193 -0
- mcpnuke/checks/ssrf_probe.py +136 -0
- mcpnuke/checks/supply_chain.py +33 -0
- mcpnuke/checks/taxonomy_coverage.py +729 -0
- mcpnuke/checks/teleport.py +258 -0
- mcpnuke/checks/teleport_labs.py +295 -0
- mcpnuke/checks/temporal_consistency.py +88 -0
- mcpnuke/checks/theft.py +56 -0
- mcpnuke/checks/tool_output_poisoning.py +161 -0
- mcpnuke/checks/tool_probes.py +468 -0
- mcpnuke/checks/tool_response_injection.py +161 -0
- mcpnuke/checks/transport.py +77 -0
- mcpnuke/checks/webhook_persistence.py +78 -0
- mcpnuke/cli.py +895 -0
- mcpnuke/cli_steps.py +711 -0
- mcpnuke/core/__init__.py +13 -0
- mcpnuke/core/auth.py +517 -0
- mcpnuke/core/cache_hints.py +19 -0
- mcpnuke/core/chain_args.py +243 -0
- mcpnuke/core/chain_replay.py +733 -0
- mcpnuke/core/chain_templates.py +106 -0
- mcpnuke/core/constants.py +105 -0
- mcpnuke/core/enumerator.py +317 -0
- mcpnuke/core/llm.py +547 -0
- mcpnuke/core/llm_ollama.py +552 -0
- mcpnuke/core/llm_prompts.py +344 -0
- mcpnuke/core/models.py +140 -0
- mcpnuke/core/oast.py +266 -0
- mcpnuke/core/protocol.py +74 -0
- mcpnuke/core/session.py +379 -0
- mcpnuke/core/taxonomy.py +140 -0
- mcpnuke/core/transports/__init__.py +5 -0
- mcpnuke/core/transports/base.py +78 -0
- mcpnuke/core/transports/http.py +196 -0
- mcpnuke/core/transports/sse.py +192 -0
- mcpnuke/core/transports/stdio.py +145 -0
- mcpnuke/core/transports/toolserver.py +285 -0
- mcpnuke/core/trust.py +68 -0
- mcpnuke/data/public_targets.txt +33 -0
- mcpnuke/data/taxonomy/lanes.yaml +618 -0
- mcpnuke/data/tool_names.txt +105 -0
- mcpnuke/diff.py +254 -0
- mcpnuke/k8s/Dockerfile +22 -0
- mcpnuke/k8s/__init__.py +10 -0
- mcpnuke/k8s/discovery.py +348 -0
- mcpnuke/k8s/fingerprint.py +221 -0
- mcpnuke/k8s/manifests/cronjob.yaml +58 -0
- mcpnuke/k8s/manifests/job.yaml +55 -0
- mcpnuke/k8s/manifests/kustomization.yaml +17 -0
- mcpnuke/k8s/manifests/namespace.yaml +7 -0
- mcpnuke/k8s/manifests/rbac-impersonate.yaml +30 -0
- mcpnuke/k8s/manifests/rbac.yaml +40 -0
- mcpnuke/k8s/manifests/serviceaccount.yaml +9 -0
- mcpnuke/k8s/scanner.py +592 -0
- mcpnuke/patterns/__init__.py +25 -0
- mcpnuke/patterns/credentials.py +293 -0
- mcpnuke/patterns/probes.py +398 -0
- mcpnuke/patterns/rules.py +181 -0
- mcpnuke/patterns/tokens.py +32 -0
- mcpnuke/policy/__init__.py +6 -0
- mcpnuke/policy/generator.py +259 -0
- mcpnuke/policy/nullfield.py +120 -0
- mcpnuke/policy/rules.py +146 -0
- mcpnuke/profile.py +123 -0
- mcpnuke/py.typed +0 -0
- mcpnuke/reporting/__init__.py +35 -0
- mcpnuke/reporting/by_lane.py +224 -0
- mcpnuke/reporting/console.py +188 -0
- mcpnuke/reporting/coverage_report.py +231 -0
- mcpnuke/reporting/diff.py +124 -0
- mcpnuke/reporting/files.py +22 -0
- mcpnuke/reporting/json_out.py +178 -0
- mcpnuke/reporting/lab_coverage.py +89 -0
- mcpnuke/reporting/owasp.py +229 -0
- mcpnuke/reporting/priority.py +410 -0
- mcpnuke/reporting/sarif.py +168 -0
- mcpnuke/reporting/taxonomy_coverage.py +179 -0
- mcpnuke/scanner.py +358 -0
- mcpnuke/server/__init__.py +16 -0
- mcpnuke/server/app.py +109 -0
- mcpnuke/server/models.py +96 -0
- mcpnuke/server/runner.py +207 -0
- mcpnuke/subcommands.py +205 -0
- mcpnuke-6.22.0.dist-info/METADATA +372 -0
- mcpnuke-6.22.0.dist-info/RECORD +132 -0
- mcpnuke-6.22.0.dist-info/WHEEL +4 -0
- mcpnuke-6.22.0.dist-info/entry_points.txt +3 -0
- mcpnuke-6.22.0.dist-info/licenses/LICENSE +21 -0
mcpnuke/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mcpnuke — MCP Red Teaming & Security Scanner
|
|
3
|
+
|
|
4
|
+
Security auditing for Model Context Protocol servers. Enumerates tools,
|
|
5
|
+
probes for injection and poisoning, detects rug pulls, and audits
|
|
6
|
+
Kubernetes MCP deployments.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
mcpnuke --targets http://localhost:9090
|
|
10
|
+
mcpnuke --port-range localhost:9001-9010 --verbose
|
|
11
|
+
python -m mcpnuke --targets http://localhost:9090
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "6.22.0"
|
mcpnuke/__main__.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
mcpnuke — MCP Red Teaming & Security Scanner
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
mcpnuke --targets http://localhost:9090
|
|
7
|
+
mcpnuke --port-range localhost:9001-9010 --verbose
|
|
8
|
+
mcpnuke --targets http://target:9090 --auth-token $TOKEN --json report.json
|
|
9
|
+
mcpnuke --stdio 'npx -y @modelcontextprotocol/server-everything'
|
|
10
|
+
mcpnuke --targets http://target:9090 --fast --group-findings
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
|
|
20
|
+
from mcpnuke import __version__
|
|
21
|
+
from mcpnuke.cli import build_url_list, parse_args
|
|
22
|
+
from mcpnuke.cli_steps import (
|
|
23
|
+
_apply_baseline,
|
|
24
|
+
_base_probe_opts,
|
|
25
|
+
_k8s_setup,
|
|
26
|
+
_print_mode_notices,
|
|
27
|
+
_print_scan_panel,
|
|
28
|
+
_report,
|
|
29
|
+
_resolve_auth,
|
|
30
|
+
_should_fail,
|
|
31
|
+
_start_oast,
|
|
32
|
+
_validate_ai_backend,
|
|
33
|
+
_watch_lanes,
|
|
34
|
+
)
|
|
35
|
+
from mcpnuke.core.auth import parse_header_kv_pairs
|
|
36
|
+
from mcpnuke.core.models import TargetResult
|
|
37
|
+
from mcpnuke.core.trust import parse_trust_sets
|
|
38
|
+
from mcpnuke.diff import (
|
|
39
|
+
load_baseline,
|
|
40
|
+
save_baseline,
|
|
41
|
+
)
|
|
42
|
+
from mcpnuke.patterns.credentials import redact_credentials
|
|
43
|
+
from mcpnuke.scanner import run_parallel, scan_stdio_target, scan_target
|
|
44
|
+
from mcpnuke.subcommands import (
|
|
45
|
+
EXIT_CLEAN,
|
|
46
|
+
EXIT_ERROR,
|
|
47
|
+
EXIT_FINDINGS,
|
|
48
|
+
_run_diff_subcommand,
|
|
49
|
+
_run_doctor,
|
|
50
|
+
_run_lab_coverage,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _main_inner() -> None:
|
|
55
|
+
# Handle `mcpnuke diff a.json b.json` before full arg parse
|
|
56
|
+
if len(sys.argv) >= 2 and sys.argv[1] == "diff":
|
|
57
|
+
_run_diff_subcommand(sys.argv[2:])
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
args = parse_args()
|
|
61
|
+
console = Console(no_color=args.no_color, force_terminal=not args.no_color)
|
|
62
|
+
from mcpnuke.core.llm import configure_bedrock
|
|
63
|
+
configure_bedrock(enabled=False)
|
|
64
|
+
try:
|
|
65
|
+
extra_headers = parse_header_kv_pairs(getattr(args, "header", None))
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
68
|
+
sys.exit(EXIT_ERROR)
|
|
69
|
+
if args.dpop_proof:
|
|
70
|
+
extra_headers["DPoP"] = args.dpop_proof
|
|
71
|
+
|
|
72
|
+
if args.doctor:
|
|
73
|
+
_run_doctor(console)
|
|
74
|
+
sys.exit(EXIT_CLEAN)
|
|
75
|
+
|
|
76
|
+
if getattr(args, "lab_coverage", None):
|
|
77
|
+
_run_lab_coverage(args.lab_coverage, console, getattr(args, "json_out", None))
|
|
78
|
+
sys.exit(EXIT_CLEAN)
|
|
79
|
+
|
|
80
|
+
_validate_ai_backend(args)
|
|
81
|
+
|
|
82
|
+
deterministic_mode: bool = bool(getattr(args, "deterministic", False))
|
|
83
|
+
effective_probe_workers: int = args.probe_workers
|
|
84
|
+
effective_phase2_workers: int = args.claude_phase2_workers
|
|
85
|
+
if deterministic_mode:
|
|
86
|
+
effective_probe_workers = 1
|
|
87
|
+
effective_phase2_workers = 1
|
|
88
|
+
|
|
89
|
+
# --stdio mode: scan a local server via stdin/stdout, then exit
|
|
90
|
+
if args.stdio:
|
|
91
|
+
probe_opts = _base_probe_opts(
|
|
92
|
+
args,
|
|
93
|
+
extra_headers=extra_headers,
|
|
94
|
+
probe_workers=effective_probe_workers,
|
|
95
|
+
phase2_workers=effective_phase2_workers,
|
|
96
|
+
deterministic=deterministic_mode,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
panel_lines = [
|
|
100
|
+
f"[bold cyan]mcpnuke v{__version__}[/bold cyan] [dim]MCP Red Teaming & Security Scanner[/dim]",
|
|
101
|
+
"Mode : stdio",
|
|
102
|
+
f"Command : {args.stdio}",
|
|
103
|
+
f"Fast : {args.fast}",
|
|
104
|
+
f"Workers : {effective_probe_workers} probe thread(s)",
|
|
105
|
+
f"Started : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
106
|
+
]
|
|
107
|
+
if deterministic_mode:
|
|
108
|
+
panel_lines.append("Deterministic: True")
|
|
109
|
+
console.print(Panel("\n".join(panel_lines), title="mcpnuke", border_style="cyan"))
|
|
110
|
+
|
|
111
|
+
result = scan_stdio_target(
|
|
112
|
+
args.stdio,
|
|
113
|
+
timeout=args.timeout,
|
|
114
|
+
verbose=args.verbose,
|
|
115
|
+
probe_opts=probe_opts,
|
|
116
|
+
)
|
|
117
|
+
_report(args, [result], console)
|
|
118
|
+
if _should_fail(result.findings, getattr(args, "fail_on", "high")):
|
|
119
|
+
sys.exit(EXIT_FINDINGS)
|
|
120
|
+
sys.exit(EXIT_CLEAN)
|
|
121
|
+
|
|
122
|
+
inference_only = (
|
|
123
|
+
getattr(args, "inference_host", None)
|
|
124
|
+
and not args.targets
|
|
125
|
+
and not args.targets_file
|
|
126
|
+
and not getattr(args, "public_targets", False)
|
|
127
|
+
and not args.port_range
|
|
128
|
+
)
|
|
129
|
+
if inference_only or (
|
|
130
|
+
args.k8s_discover
|
|
131
|
+
and not args.targets
|
|
132
|
+
and not args.targets_file
|
|
133
|
+
and not getattr(args, "public_targets", False)
|
|
134
|
+
and not args.port_range
|
|
135
|
+
):
|
|
136
|
+
urls = []
|
|
137
|
+
else:
|
|
138
|
+
urls = build_url_list(args)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
trust_sets = parse_trust_sets(getattr(args, "trust_set", None), targets=urls)
|
|
142
|
+
except ValueError as exc:
|
|
143
|
+
console.print(f" [red]✗[/red] {exc}")
|
|
144
|
+
sys.exit(EXIT_ERROR)
|
|
145
|
+
|
|
146
|
+
auth_token, auth_context_summary = _resolve_auth(args, urls, extra_headers, console)
|
|
147
|
+
|
|
148
|
+
baseline = {}
|
|
149
|
+
if args.baseline:
|
|
150
|
+
baseline = load_baseline(args.baseline)
|
|
151
|
+
if not baseline:
|
|
152
|
+
console.print(f"[yellow]Baseline empty or not found: {args.baseline}[/yellow]")
|
|
153
|
+
|
|
154
|
+
_print_scan_panel(
|
|
155
|
+
args,
|
|
156
|
+
urls,
|
|
157
|
+
auth_token=auth_token,
|
|
158
|
+
auth_context_summary=auth_context_summary,
|
|
159
|
+
extra_headers=extra_headers,
|
|
160
|
+
probe_workers=effective_probe_workers,
|
|
161
|
+
phase2_workers=effective_phase2_workers,
|
|
162
|
+
deterministic=deterministic_mode,
|
|
163
|
+
console=console,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
profile_data = None
|
|
167
|
+
if getattr(args, "profile", None):
|
|
168
|
+
from mcpnuke.profile import load_profile
|
|
169
|
+
try:
|
|
170
|
+
profile_data = load_profile(args.profile)
|
|
171
|
+
console.print(f" [green]Profile loaded:[/green] {profile_data.name} ({len(profile_data.tools)} tools)")
|
|
172
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
173
|
+
console.print(f" [red]Profile error:[/red] {exc}")
|
|
174
|
+
|
|
175
|
+
probe_opts = {
|
|
176
|
+
**_base_probe_opts(
|
|
177
|
+
args,
|
|
178
|
+
extra_headers=extra_headers,
|
|
179
|
+
probe_workers=effective_probe_workers,
|
|
180
|
+
phase2_workers=effective_phase2_workers,
|
|
181
|
+
deterministic=deterministic_mode,
|
|
182
|
+
),
|
|
183
|
+
"trust_sets": [sorted(group) for group in trust_sets],
|
|
184
|
+
"target_count": len(urls),
|
|
185
|
+
"auth_context_summary": auth_context_summary,
|
|
186
|
+
"profile": profile_data,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
oast_listener = _start_oast(args, console)
|
|
190
|
+
if oast_listener is not None:
|
|
191
|
+
probe_opts["oast"] = oast_listener
|
|
192
|
+
|
|
193
|
+
_print_mode_notices(args, deterministic_mode, console)
|
|
194
|
+
|
|
195
|
+
_k8s_setup(args, urls, console)
|
|
196
|
+
|
|
197
|
+
if not urls and inference_only:
|
|
198
|
+
from mcpnuke.checks.inference_backend import check_inference_backend, check_model_integrity
|
|
199
|
+
t0 = time.time()
|
|
200
|
+
result = TargetResult(url=probe_opts["inference_host"])
|
|
201
|
+
result.transport = "inference-only"
|
|
202
|
+
check_inference_backend(result, probe_opts=probe_opts)
|
|
203
|
+
if probe_opts.get("inference_baseline") or probe_opts.get("save_inference_baseline"):
|
|
204
|
+
check_model_integrity(result, probe_opts=probe_opts)
|
|
205
|
+
result.timings["total"] = time.time() - t0
|
|
206
|
+
results = [result]
|
|
207
|
+
_report(args, results, console)
|
|
208
|
+
if probe_opts.get("save_inference_baseline"):
|
|
209
|
+
console.print(f"\n [green]✓[/green] Inference baseline saved to {probe_opts['save_inference_baseline']}")
|
|
210
|
+
from mcpnuke.k8s.scanner import GLOBAL_K8S_FINDINGS
|
|
211
|
+
if _should_fail(result.findings + GLOBAL_K8S_FINDINGS, getattr(args, "fail_on", "high")):
|
|
212
|
+
sys.exit(EXIT_FINDINGS)
|
|
213
|
+
sys.exit(EXIT_CLEAN)
|
|
214
|
+
|
|
215
|
+
if not urls:
|
|
216
|
+
from mcpnuke.k8s.scanner import GLOBAL_K8S_FINDINGS
|
|
217
|
+
if GLOBAL_K8S_FINDINGS:
|
|
218
|
+
console.print(f"\n[bold]── K8s-Only Report ({len(GLOBAL_K8S_FINDINGS)} findings) ──[/bold]")
|
|
219
|
+
_report(args, [], console)
|
|
220
|
+
if _should_fail(GLOBAL_K8S_FINDINGS, getattr(args, "fail_on", "high")):
|
|
221
|
+
sys.exit(EXIT_FINDINGS)
|
|
222
|
+
sys.exit(EXIT_CLEAN)
|
|
223
|
+
console.print("[red]No targets specified and K8s discovery found nothing.[/red]")
|
|
224
|
+
sys.exit(EXIT_ERROR)
|
|
225
|
+
|
|
226
|
+
if len(urls) == 1:
|
|
227
|
+
results = [
|
|
228
|
+
scan_target(
|
|
229
|
+
urls[0],
|
|
230
|
+
[],
|
|
231
|
+
timeout=args.timeout,
|
|
232
|
+
verbose=args.verbose,
|
|
233
|
+
auth_token=auth_token,
|
|
234
|
+
probe_opts=probe_opts,
|
|
235
|
+
)
|
|
236
|
+
]
|
|
237
|
+
else:
|
|
238
|
+
results = run_parallel(
|
|
239
|
+
urls,
|
|
240
|
+
timeout=args.timeout,
|
|
241
|
+
workers=args.workers,
|
|
242
|
+
verbose=args.verbose,
|
|
243
|
+
auth_token=auth_token,
|
|
244
|
+
probe_opts=probe_opts,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
_apply_baseline(args, results, baseline, console)
|
|
248
|
+
|
|
249
|
+
_report(args, results, console)
|
|
250
|
+
if args.save_baseline:
|
|
251
|
+
save_baseline(results, args.save_baseline, console=console)
|
|
252
|
+
|
|
253
|
+
if getattr(args, "watch", None):
|
|
254
|
+
_watch_lanes(args, results, urls, auth_token, probe_opts, baseline, console)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
from mcpnuke.k8s.scanner import GLOBAL_K8S_FINDINGS
|
|
258
|
+
all_findings = [f for r in results for f in r.findings] + GLOBAL_K8S_FINDINGS
|
|
259
|
+
if _should_fail(all_findings, getattr(args, "fail_on", "high")):
|
|
260
|
+
sys.exit(EXIT_FINDINGS)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main() -> None:
|
|
264
|
+
try:
|
|
265
|
+
_main_inner()
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
print(f"mcpnuke: {type(exc).__name__}: {redact_credentials(str(exc))}", file=sys.stderr)
|
|
268
|
+
sys.exit(EXIT_ERROR)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|
mcpnuke/_docsgen.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Paths to the reference documents, and the renderer for the generated ones.
|
|
2
|
+
|
|
3
|
+
Two kinds of document are named here and only one of them is generated.
|
|
4
|
+
|
|
5
|
+
The CLI reference is derived from the argparse parser rather than maintained by
|
|
6
|
+
hand, because the hand-maintained copy drifted 14 flags behind before anyone
|
|
7
|
+
noticed. tests/test_docs_current.py fails the build if the committed output
|
|
8
|
+
stops matching the parser.
|
|
9
|
+
|
|
10
|
+
docs/checks.md is the other kind: hand-written, because a check's severity and
|
|
11
|
+
what it detects cannot be derived from its source. Its path lives here so that
|
|
12
|
+
the test guarding it can reference one place where doc paths are declared,
|
|
13
|
+
which is the only thing the two have in common.
|
|
14
|
+
|
|
15
|
+
DANGER: main() must never write CHECKS_PATH. Adding it there for symmetry with
|
|
16
|
+
CLI_REFERENCE_PATH would overwrite a document nothing can regenerate, and would
|
|
17
|
+
do it silently, on a command whose whole contract is "safe to re-run".
|
|
18
|
+
test_generator_never_writes_the_hand_written_doc fails if main() so much as
|
|
19
|
+
names it.
|
|
20
|
+
|
|
21
|
+
Regenerate: uv run python -m mcpnuke._docsgen
|
|
22
|
+
|
|
23
|
+
Never import this module from runtime code. REPO_ROOT walks up from __file__
|
|
24
|
+
to find a checkout, which is meaningless in an installed wheel — the package
|
|
25
|
+
ships whole, so this file is present but its paths point nowhere useful. It is
|
|
26
|
+
a build-time tool that happens to live inside the package so it can import the
|
|
27
|
+
parsers it documents.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import ast
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import TypeGuard
|
|
36
|
+
|
|
37
|
+
from mcpnuke.cli import build_parser
|
|
38
|
+
from mcpnuke.subcommands import _build_diff_parser
|
|
39
|
+
|
|
40
|
+
REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
|
41
|
+
CLI_REFERENCE_PATH: Path = REPO_ROOT / "docs" / "cli-reference.md"
|
|
42
|
+
|
|
43
|
+
# Hand-written, unlike the CLI reference: severity and detection prose cannot be
|
|
44
|
+
# derived from source. Named here so the completeness test that guards it does
|
|
45
|
+
# not have to rebuild a repo-root path of its own. Read-only to this module —
|
|
46
|
+
# see the warning in the module docstring before using it anywhere that writes.
|
|
47
|
+
CHECKS_PATH: Path = REPO_ROOT / "docs" / "checks.md"
|
|
48
|
+
|
|
49
|
+
# Read as text, not imported: the association between an env var and the flag
|
|
50
|
+
# it backs exists only in the source, since argparse keeps the resolved value
|
|
51
|
+
# and throws the variable name away.
|
|
52
|
+
CLI_SOURCE_PATH: Path = Path(__file__).resolve().parent / "cli.py"
|
|
53
|
+
|
|
54
|
+
BANNER: str = (
|
|
55
|
+
"<!-- GENERATED FILE — do not edit by hand.\n"
|
|
56
|
+
" Regenerate: uv run python -m mcpnuke._docsgen\n"
|
|
57
|
+
" Source: mcpnuke/cli.py -->"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# argparse's own groups. "options" holds only -h/--help after the flags were
|
|
61
|
+
# split into named groups, so rendering it would emit a stub section.
|
|
62
|
+
_SKIP_TITLES: frozenset[str] = frozenset({"positional arguments", "options"})
|
|
63
|
+
|
|
64
|
+
ENV_HEADING: str = "Environment Variables"
|
|
65
|
+
DIFF_HEADING: str = "Subcommand: `mcpnuke diff`"
|
|
66
|
+
|
|
67
|
+
# The only prose in the generated document. Everything factual — every option,
|
|
68
|
+
# positional and variable name — comes from a parser or from the source.
|
|
69
|
+
_DIFF_PROSE: str = (
|
|
70
|
+
"Compares two saved scan reports. `diff` is dispatched off `sys.argv` "
|
|
71
|
+
"before the main parser runs, so it takes its own arguments and none of "
|
|
72
|
+
"the options above."
|
|
73
|
+
)
|
|
74
|
+
_DIFF_EXIT_NOTE: str = (
|
|
75
|
+
"Exits 1 when the newer report contains findings the baseline did not, "
|
|
76
|
+
"so it can gate a CI job."
|
|
77
|
+
)
|
|
78
|
+
_ENV_PROSE: str = (
|
|
79
|
+
"Each variable supplies the default for one flag; passing the flag wins. "
|
|
80
|
+
"Only names are listed here — a value is a credential."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_environ_get(node: ast.AST) -> TypeGuard[ast.Call]:
|
|
85
|
+
"""True for an `os.environ.get(...)` call node."""
|
|
86
|
+
return (
|
|
87
|
+
isinstance(node, ast.Call)
|
|
88
|
+
and isinstance(node.func, ast.Attribute)
|
|
89
|
+
and node.func.attr == "get"
|
|
90
|
+
and getattr(node.func.value, "attr", "") == "environ"
|
|
91
|
+
and bool(node.args)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _module_string_constants(tree: ast.Module) -> dict[str, str]:
|
|
96
|
+
"""Module-level `NAME = "literal"` bindings, for resolving indirection.
|
|
97
|
+
|
|
98
|
+
`--auth-token` reads `os.environ.get(AUTH_TOKEN_ENV)`. A literal-only walk
|
|
99
|
+
finds 13 of the 14 variables and drops MCP_AUTH_TOKEN, the one most worth
|
|
100
|
+
documenting.
|
|
101
|
+
"""
|
|
102
|
+
return {
|
|
103
|
+
target.id: node.value.value
|
|
104
|
+
for node in tree.body
|
|
105
|
+
if isinstance(node, ast.Assign)
|
|
106
|
+
and isinstance(node.value, ast.Constant)
|
|
107
|
+
and isinstance(node.value.value, str)
|
|
108
|
+
for target in node.targets
|
|
109
|
+
if isinstance(target, ast.Name)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _environ_name(node: ast.AST, constants: dict[str, str]) -> str | None:
|
|
114
|
+
"""The variable name an `os.environ.get(...)` call reads, if it is static."""
|
|
115
|
+
if not _is_environ_get(node):
|
|
116
|
+
return None
|
|
117
|
+
key = node.args[0]
|
|
118
|
+
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
|
119
|
+
return key.value
|
|
120
|
+
if isinstance(key, ast.Name):
|
|
121
|
+
return constants.get(key.id)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_cli_source() -> tuple[ast.Module, dict[str, str]]:
|
|
126
|
+
tree = ast.parse(CLI_SOURCE_PATH.read_text())
|
|
127
|
+
return tree, _module_string_constants(tree)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def count_environ_reads() -> int:
|
|
131
|
+
"""How many `os.environ.get(...)` calls cli.py makes with a static name.
|
|
132
|
+
|
|
133
|
+
Exists so a test can assert the flag association below loses none of them;
|
|
134
|
+
a silent drop would put a variable back in the undocumented pile.
|
|
135
|
+
"""
|
|
136
|
+
tree, constants = _parse_cli_source()
|
|
137
|
+
return len({
|
|
138
|
+
name
|
|
139
|
+
for node in ast.walk(tree)
|
|
140
|
+
if (name := _environ_name(node, constants)) is not None
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def env_var_flag_pairs() -> list[tuple[str, str]]:
|
|
145
|
+
"""Every env var cli.py reads, paired with the flag whose default it sets.
|
|
146
|
+
|
|
147
|
+
Walks `add_argument` calls and takes the first string positional as the
|
|
148
|
+
flag, so the pairing comes from the source rather than a list someone has
|
|
149
|
+
to remember to update.
|
|
150
|
+
"""
|
|
151
|
+
tree, constants = _parse_cli_source()
|
|
152
|
+
pairs: set[tuple[str, str]] = set()
|
|
153
|
+
for node in ast.walk(tree):
|
|
154
|
+
if not (
|
|
155
|
+
isinstance(node, ast.Call)
|
|
156
|
+
and isinstance(node.func, ast.Attribute)
|
|
157
|
+
and node.func.attr == "add_argument"
|
|
158
|
+
):
|
|
159
|
+
continue
|
|
160
|
+
flag = next(
|
|
161
|
+
(
|
|
162
|
+
arg.value
|
|
163
|
+
for arg in node.args
|
|
164
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)
|
|
165
|
+
),
|
|
166
|
+
None,
|
|
167
|
+
)
|
|
168
|
+
if flag is None:
|
|
169
|
+
continue
|
|
170
|
+
for inner in ast.walk(node):
|
|
171
|
+
name = _environ_name(inner, constants)
|
|
172
|
+
if name is not None:
|
|
173
|
+
pairs.add((name, flag))
|
|
174
|
+
return sorted(pairs)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _help_text(action: argparse.Action) -> str:
|
|
178
|
+
"""The help string as `--help` shows it, without expanding any values.
|
|
179
|
+
|
|
180
|
+
argparse %-formats help strings before printing, so a literal percent is
|
|
181
|
+
written `%%` in the source. Undoing that escape here keeps the doc matching
|
|
182
|
+
`--help`. Note this is *not* argparse's `_expand_help`: that would also
|
|
183
|
+
substitute `%(default)s`, and fourteen defaults come from `os.environ.get`,
|
|
184
|
+
several of them secrets. Nothing here ever reads a default.
|
|
185
|
+
"""
|
|
186
|
+
return (action.help or "").replace("%%", "%")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _cell(text: str) -> str:
|
|
190
|
+
"""Collapse whitespace and escape pipes so a table row survives markdown."""
|
|
191
|
+
return " ".join(text.split()).replace("|", "\\|")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _row(formatter: argparse.HelpFormatter, action: argparse.Action) -> str:
|
|
195
|
+
"""One table row: the invocation exactly as --help prints it, plus help."""
|
|
196
|
+
invocation = formatter._format_action_invocation(action)
|
|
197
|
+
return f"| `{_cell(invocation)}` | {_cell(_help_text(action))} |"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _table(header: str) -> list[str]:
|
|
201
|
+
return [f"| {header} | Description |", "|---|---|"]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _render_flag_groups(parser: argparse.ArgumentParser) -> list[str]:
|
|
205
|
+
"""One section per curated argument group, in declaration order."""
|
|
206
|
+
formatter = parser._get_formatter()
|
|
207
|
+
lines: list[str] = []
|
|
208
|
+
for group in parser._action_groups:
|
|
209
|
+
if group.title in _SKIP_TITLES:
|
|
210
|
+
continue
|
|
211
|
+
actions = [
|
|
212
|
+
a
|
|
213
|
+
for a in group._group_actions
|
|
214
|
+
if a.help != argparse.SUPPRESS and a.option_strings
|
|
215
|
+
]
|
|
216
|
+
if not actions:
|
|
217
|
+
continue
|
|
218
|
+
lines += [f"## {group.title}", "", *_table("Option")]
|
|
219
|
+
lines += [_row(formatter, a) for a in actions]
|
|
220
|
+
lines.append("")
|
|
221
|
+
return lines
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _render_env_vars() -> list[str]:
|
|
225
|
+
return [
|
|
226
|
+
f"## {ENV_HEADING}",
|
|
227
|
+
"",
|
|
228
|
+
_ENV_PROSE,
|
|
229
|
+
"",
|
|
230
|
+
"| Variable | Flag |",
|
|
231
|
+
"|---|---|",
|
|
232
|
+
*(f"| `{var}` | `{flag}` |" for var, flag in env_var_flag_pairs()),
|
|
233
|
+
"",
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _render_diff_subcommand(parser: argparse.ArgumentParser) -> list[str]:
|
|
238
|
+
"""The `diff` section, tables generated, prose hand-written.
|
|
239
|
+
|
|
240
|
+
Rendered separately because `diff` has positionals, which the main
|
|
241
|
+
renderer filters out rather than loosening its own filter for one section.
|
|
242
|
+
"""
|
|
243
|
+
formatter = parser._get_formatter()
|
|
244
|
+
actions = [
|
|
245
|
+
a
|
|
246
|
+
for a in parser._actions
|
|
247
|
+
if a.help != argparse.SUPPRESS and "--help" not in a.option_strings
|
|
248
|
+
]
|
|
249
|
+
return [
|
|
250
|
+
f"## {DIFF_HEADING}",
|
|
251
|
+
"",
|
|
252
|
+
_DIFF_PROSE,
|
|
253
|
+
"",
|
|
254
|
+
"```bash",
|
|
255
|
+
"mcpnuke diff OLD.json NEW.json",
|
|
256
|
+
"```",
|
|
257
|
+
"",
|
|
258
|
+
*_table("Argument"),
|
|
259
|
+
*(_row(formatter, a) for a in actions),
|
|
260
|
+
"",
|
|
261
|
+
_DIFF_EXIT_NOTE,
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def render_cli_reference(
|
|
266
|
+
parser: argparse.ArgumentParser,
|
|
267
|
+
diff_parser: argparse.ArgumentParser | None = None,
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Render the full CLI reference as markdown.
|
|
270
|
+
|
|
271
|
+
Only invocations and help text are emitted. Fourteen flags default to
|
|
272
|
+
`os.environ.get(...)`, several of them secrets, so a defaults column would
|
|
273
|
+
bake the generating machine's credentials into a committed file.
|
|
274
|
+
"""
|
|
275
|
+
lines: list[str] = [
|
|
276
|
+
BANNER,
|
|
277
|
+
"",
|
|
278
|
+
"# CLI Reference",
|
|
279
|
+
"",
|
|
280
|
+
"Every option `mcpnuke` accepts, grouped as `--help` groups them.",
|
|
281
|
+
"Generated from the parser, so it cannot fall behind the code.",
|
|
282
|
+
"",
|
|
283
|
+
*_render_flag_groups(parser),
|
|
284
|
+
*_render_env_vars(),
|
|
285
|
+
*_render_diff_subcommand(diff_parser or _build_diff_parser()),
|
|
286
|
+
]
|
|
287
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def main() -> None:
|
|
291
|
+
CLI_REFERENCE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
CLI_REFERENCE_PATH.write_text(render_cli_reference(build_parser()))
|
|
293
|
+
print(f"wrote {CLI_REFERENCE_PATH.relative_to(REPO_ROOT)}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__":
|
|
297
|
+
main()
|
mcpnuke/_runner_entry.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Console-script shim for ``mcpnuke-runner``.
|
|
2
|
+
|
|
3
|
+
The runner needs the optional ``server`` extra (fastapi, uvicorn, pydantic),
|
|
4
|
+
but its console script is installed by the base package. Pointing the script
|
|
5
|
+
straight at ``mcpnuke.server.app:run`` meant a plain ``pip install mcpnuke``
|
|
6
|
+
followed by ``mcpnuke-runner`` printed a ModuleNotFoundError traceback.
|
|
7
|
+
|
|
8
|
+
This module sits deliberately *outside* ``mcpnuke.server``: that package's
|
|
9
|
+
``__init__`` imports pydantic through ``models``, so any handler written
|
|
10
|
+
inside it would need the very dependency it is trying to report on.
|
|
11
|
+
|
|
12
|
+
Nothing here may import the server package at module level.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
# The distribution names that make up the `server` extra. A missing import
|
|
21
|
+
# outside this set is a real bug in the server package, and is re-raised
|
|
22
|
+
# rather than reported as a missing extra.
|
|
23
|
+
_SERVER_EXTRA_MODULES: frozenset[str] = frozenset({"fastapi", "uvicorn", "pydantic"})
|
|
24
|
+
|
|
25
|
+
_EXIT_MISSING_EXTRA: int = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_run() -> Callable[[], None]:
|
|
29
|
+
"""Import the real entry point. Kept separate so tests can replace it."""
|
|
30
|
+
from mcpnuke.server.app import run
|
|
31
|
+
|
|
32
|
+
return run
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> None:
|
|
36
|
+
try:
|
|
37
|
+
run = _load_run()
|
|
38
|
+
except ImportError as exc:
|
|
39
|
+
missing = (getattr(exc, "name", "") or "").split(".")[0]
|
|
40
|
+
if missing not in _SERVER_EXTRA_MODULES:
|
|
41
|
+
raise
|
|
42
|
+
sys.stderr.write(
|
|
43
|
+
f"mcpnuke-runner needs the 'server' extra, and {missing} is not installed.\n"
|
|
44
|
+
"\n"
|
|
45
|
+
" uv tool install 'mcpnuke[server]'\n"
|
|
46
|
+
" pip install 'mcpnuke[server]'\n"
|
|
47
|
+
"\n"
|
|
48
|
+
"The scanner itself does not need it — plain 'mcpnuke' works without.\n"
|
|
49
|
+
)
|
|
50
|
+
raise SystemExit(_EXIT_MISSING_EXTRA) from None
|
|
51
|
+
run()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|