netexec-mcp 1.0.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.
@@ -0,0 +1,3 @@
1
+ """netexec-mcp: an MCP server that drives NetExec (nxc) for authorized security testing."""
2
+
3
+ __version__ = "0.1.0"
netexec_mcp/auth.py ADDED
@@ -0,0 +1,156 @@
1
+ """Shared authentication model -> nxc flags.
2
+
3
+ Every protocol module accepts the same credential parameters; this module turns
4
+ them into the argv flags nxc expects, with light validation of nonsensical
5
+ combinations. It builds *only* the auth-related flags -- targets and per-tool
6
+ action flags (e.g. ``--shares``) are added by the caller.
7
+
8
+ Mapping (per PLAN.md "Shared auth model -> flags"):
9
+ username -> -u
10
+ password -> -p
11
+ ntlm_hash -> -H (pass-the-hash)
12
+ domain -> -d
13
+ local_auth -> --local-auth
14
+ kerberos -> -k
15
+ use_kcache -> --use-kcache (implies kerberos)
16
+ cred_id -> -id (use a credential already stored in nxc's DB)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+
22
+ class AuthError(ValueError):
23
+ """Raised for contradictory credential parameters (nothing runs)."""
24
+
25
+
26
+ def _as_list(value) -> list[str]:
27
+ """Normalise a str | list[str] | None credential field to a list of tokens."""
28
+ if value is None:
29
+ return []
30
+ if isinstance(value, str):
31
+ return [value]
32
+ return [str(v) for v in value]
33
+
34
+
35
+ # The blank/empty-LM hash. nxc's DB stores a pass-the-hash secret as `<LM>:<NT>`, and for a
36
+ # modern account the LM half is this constant. Passing the full `<blankLM>:<NT>` can fail
37
+ # auth (observed live: NTDS dump -> STATUS_LOGON_FAILURE on a DC that refuses LM) while the
38
+ # bare NT hash succeeds -- so reduce a blank-LM pair to just the NT half.
39
+ _BLANK_LM = "aad3b435b51404eeaad3b435b51404ee"
40
+
41
+
42
+ def _nt_only(h: str) -> str:
43
+ """Reduce a `LM:NT` hash whose LM half is the blank-LM value (or empty) to the bare NT
44
+ hash. A real (non-blank) LM half, a plain NT hash, and a non-hash token (e.g. a wordlist
45
+ path) are returned unchanged -- only an exact blank-LM prefix is stripped."""
46
+ lm, sep, nt = h.partition(":")
47
+ if sep and nt and (lm.lower() == _BLANK_LM or lm == ""):
48
+ return nt
49
+ return h
50
+
51
+
52
+ def build_auth_flags(
53
+ username: "str | list[str] | None" = None,
54
+ password: "str | list[str] | None" = None,
55
+ ntlm_hash: "str | list[str] | None" = None,
56
+ domain: str | None = None,
57
+ local_auth: bool = False,
58
+ kerberos: bool = False,
59
+ use_kcache: bool = False,
60
+ cred_id: int | None = None,
61
+ laps: str | None = None,
62
+ kdc_host: str | None = None,
63
+ aes_key: str | None = None,
64
+ ccache: str | None = None,
65
+ pfx_cert: str | None = None,
66
+ pfx_base64: str | None = None,
67
+ pfx_pass: str | None = None,
68
+ pem_cert: str | None = None,
69
+ pem_key: str | None = None,
70
+ ) -> list[str]:
71
+ """Translate credential parameters into an nxc auth flag list.
72
+
73
+ username/password/ntlm_hash accept either a single value or a list (for
74
+ password spraying); each list entry may be a literal value or a path to a
75
+ wordlist file that nxc reads. `laps` enables LAPS auth (`--laps <account>`):
76
+ nxc reads the target's LAPS-managed local-admin password from AD using the
77
+ supplied creds, then authenticates with it -- pass the account name (use
78
+ "administrator" for the nxc default). Raises AuthError on contradictory
79
+ combinations so a bad call is rejected before any command is built.
80
+ """
81
+ usernames = _as_list(username)
82
+ passwords = _as_list(password)
83
+ hashes = _as_list(ntlm_hash)
84
+ cert_auth = bool(pfx_cert or pfx_base64 or pem_cert)
85
+
86
+ if passwords and hashes:
87
+ raise AuthError("provide either password(s) or NTLM hash(es), not both.")
88
+ if local_auth and domain:
89
+ raise AuthError("--local-auth authenticates locally; do not also set a domain.")
90
+ if cred_id is not None and (usernames or passwords or hashes or domain or local_auth):
91
+ raise AuthError(
92
+ "cred_id uses a credential stored in nxc's DB; do not also pass "
93
+ "username/password/hash/domain/local_auth."
94
+ )
95
+ if cert_auth and not usernames:
96
+ raise AuthError("certificate authentication requires a username.")
97
+ if bool(pem_cert) != bool(pem_key):
98
+ raise AuthError("PEM certificate auth needs both pem_cert and pem_key.")
99
+
100
+ flags: list[str] = []
101
+
102
+ if cred_id is not None:
103
+ flags += ["-id", str(cred_id)]
104
+ else:
105
+ if domain:
106
+ flags += ["-d", domain]
107
+ for user in usernames:
108
+ flags += ["-u", user]
109
+ for pw in passwords:
110
+ flags += ["-p", pw]
111
+ if usernames and not passwords and not hashes and not (kerberos or use_kcache or aes_key or ccache or cert_auth):
112
+ # Username(s) with no secret = empty-password (guest/null) bind. nxc
113
+ # needs an explicit `-p ''`; emitting it here makes guest auth reliable
114
+ # without an agent having to serialise an empty string (which MCP
115
+ # clients routinely mangle). `-u guest` -> `-u guest -p ''`.
116
+ flags += ["-p", ""]
117
+ for h in hashes:
118
+ flags += ["-H", _nt_only(h)]
119
+ if local_auth:
120
+ flags.append("--local-auth")
121
+
122
+ # Certificate authentication (PKINIT / ADCS) -- a distinct method (requires a
123
+ # username, checked above). Provide a pfx (file or base64, optional --pfx-pass)
124
+ # OR a PEM cert+key pair.
125
+ if pfx_cert:
126
+ flags += ["--pfx-cert", pfx_cert]
127
+ if pfx_base64:
128
+ flags += ["--pfx-base64", pfx_base64]
129
+ if pfx_pass:
130
+ flags += ["--pfx-pass", pfx_pass]
131
+ if pem_cert:
132
+ flags += ["--pem-cert", pem_cert]
133
+ if pem_key:
134
+ flags += ["--pem-key", pem_key]
135
+
136
+ if laps:
137
+ flags += ["--laps", laps]
138
+
139
+ # Kerberos options. `--aesKey` is itself a Kerberos secret (overpass-the-key;
140
+ # pairs with the aes256/aes128 keys recovered by smb_lsa/smb_ntds) -- nxc treats
141
+ # its presence as enabling Kerberos, so no separate -k is needed. `--kdcHost`
142
+ # names the KDC and is often required for `-k` to resolve the DC.
143
+ if aes_key:
144
+ flags += ["--aesKey", aes_key]
145
+ if kdc_host:
146
+ flags += ["--kdcHost", kdc_host]
147
+
148
+ # Kerberos can layer on top of either path (e.g. -k with a username, or
149
+ # --use-kcache with a stored ticket). A `ccache` path implies --use-kcache
150
+ # (the executor points KRB5CCNAME at it; nxc has no ccache flag).
151
+ if use_kcache or ccache:
152
+ flags.append("--use-kcache")
153
+ elif kerberos:
154
+ flags.append("-k")
155
+
156
+ return flags
netexec_mcp/config.py ADDED
@@ -0,0 +1,219 @@
1
+ """Environment resolution and NetExec base-command detection.
2
+
3
+ The base command is how this server invokes nxc. Resolution order (per PLAN.md):
4
+ 1. NXC_COMMAND env (shlex-parsed) if set -- covers uv/poetry/pipx/docker installs.
5
+ 2. else auto-detect `nxc` / `netexec` on PATH.
6
+ 3. else fail fast with setup help.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import configparser
12
+ import os
13
+ import shlex
14
+ import shutil
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ # All protocol-modules nxc supports that we expose as tool groups. Enabled subset
19
+ # is controlled by NXC_PROTOCOLS; default is all of them.
20
+ DEFAULT_PROTOCOLS = [
21
+ "smb", "ldap", "winrm", "ssh", "mssql", "wmi", "rdp", "ftp", "nfs", "vnc",
22
+ ]
23
+
24
+ # The single operating-mode control (NXC_MODE), with four escalating levels:
25
+ # suggest -- return the resolved command, never execute (the auditor runs it).
26
+ # Scope is still enforced; offensive commands *can* be previewed.
27
+ # recon -- execute read-only enumeration; everything beyond it is blocked. (default)
28
+ # loot -- also permit read-only *credential-dumping* (sam/lsa/ntds/gpp/roasting):
29
+ # no state change on the target, but it harvests credential material.
30
+ # full -- execute everything, including state-changing / privilege-escalation
31
+ # actions (exec, write, spray, coercion-with-listener, exploits).
32
+ # The recon<loot<full split mirrors nxc's own module taxonomy (ENUMERATION <
33
+ # CREDENTIAL_DUMPING < PRIVILEGE_ESCALATION).
34
+ VALID_MODES = ("suggest", "recon", "loot", "full")
35
+
36
+ # How the tool surface is presented (NXC_TOOL_MODE), independent of the operating
37
+ # mode above:
38
+ # dynamic -- (DEFAULT) expose only a few meta-tools (catalog/find/describe/call);
39
+ # the real tools stay registered + callable but are hidden from tools/list.
40
+ # Keeps the ~46k-token tool surface out of the model's context window --
41
+ # what makes the server usable on small-context models (and ~8x cheaper on
42
+ # big ones, same quality). See dynamic.py. Benchmark: dynamic is net-positive
43
+ # or neutral for 4/5 models tested and the ONLY viable mode for <=8B/32k.
44
+ # static -- register every enabled tool as a first-class MCP tool (opt-out). Best for
45
+ # a decisive one-shot model, or to avoid discovery round-trips on a
46
+ # big-context model. On a small context the ~46k surface overflows -> a
47
+ # startup warning is emitted.
48
+ VALID_TOOL_MODES = ("static", "dynamic")
49
+
50
+
51
+ class ConfigError(RuntimeError):
52
+ """Raised when the server cannot determine a valid configuration."""
53
+
54
+
55
+ def nxc_home() -> Path:
56
+ """nxc's home dir, mirroring ``nxc/paths.py``: ``$NXC_PATH`` if set, else
57
+ ``~/.nxc``. Identical on Linux/macOS/Windows -- nxc uses ``expanduser`` (no
58
+ ``%APPDATA%``/appdirs), so ``~`` resolves to ``C:\\Users\\<user>`` on Windows.
59
+ Honoring nxc's own env var keeps our reads aligned with wherever nxc writes.
60
+ """
61
+ override = os.environ.get("NXC_PATH")
62
+ if override and override.strip():
63
+ return Path(os.path.expanduser(override.strip()))
64
+ return Path(os.path.expanduser("~/.nxc"))
65
+
66
+
67
+ def nxc_config_path() -> Path:
68
+ """Path to ``nxc.conf`` under the resolved nxc home."""
69
+ return nxc_home() / "nxc.conf"
70
+
71
+
72
+ def nxc_workspace_dir() -> Path:
73
+ """The ``workspaces/`` dir (per-protocol ``.db`` files) under the nxc home."""
74
+ return nxc_home() / "workspaces"
75
+
76
+
77
+ # nxc's own fallbacks when nxc.conf is absent or a key is unset.
78
+ _NXC_CONF_DEFAULTS = {"pwn3d_label": "Pwn3d!", "workspace": "default"}
79
+
80
+
81
+ def read_nxc_conf() -> dict:
82
+ """Read the operator-configurable ``[nxc]`` ``pwn3d_label`` and ``workspace``.
83
+
84
+ Both live in ``nxc.conf`` and can be changed by the operator, so we read them
85
+ from that single source of truth instead of hardcoding. Falls back to nxc's
86
+ own defaults when the file is missing/unreadable. ``pwn3d_label`` is preserved
87
+ verbatim when the key exists (even if blank); a blank ``workspace`` collapses
88
+ to ``default``.
89
+ """
90
+ parser = configparser.ConfigParser(interpolation=None) # values may contain %
91
+ try:
92
+ with open(nxc_config_path(), encoding="utf-8") as fh:
93
+ parser.read_file(fh)
94
+ except (OSError, configparser.Error):
95
+ return dict(_NXC_CONF_DEFAULTS)
96
+ if not parser.has_section("nxc"):
97
+ return dict(_NXC_CONF_DEFAULTS)
98
+ section = parser["nxc"]
99
+ label = section.get("pwn3d_label", _NXC_CONF_DEFAULTS["pwn3d_label"])
100
+ workspace = (section.get("workspace") or "").strip() or _NXC_CONF_DEFAULTS["workspace"]
101
+ return {"pwn3d_label": label, "workspace": workspace}
102
+
103
+
104
+ def _env_list(name: str) -> list[str]:
105
+ """Parse a comma/newline-separated env var into a clean list of tokens."""
106
+ raw = os.environ.get(name, "")
107
+ return [p.strip() for p in raw.replace("\n", ",").split(",") if p.strip()]
108
+
109
+
110
+ def resolve_base_command() -> list[str]:
111
+ """Resolve the nxc base command as an argv list (never via a shell).
112
+
113
+ Tokens are user-expanded so values like
114
+ `uv run --directory ~/NetExec netexec` work as written.
115
+ """
116
+ explicit = os.environ.get("NXC_COMMAND")
117
+ if explicit and explicit.strip():
118
+ argv = [os.path.expanduser(tok) for tok in shlex.split(explicit)]
119
+ if not argv:
120
+ raise ConfigError("NXC_COMMAND is set but empty after parsing.")
121
+ return argv
122
+
123
+ for candidate in ("nxc", "netexec"):
124
+ found = shutil.which(candidate)
125
+ if found:
126
+ return [found]
127
+
128
+ raise ConfigError(
129
+ "Could not find NetExec. Set NXC_COMMAND (e.g. "
130
+ "\"uv run --directory ~/NetExec netexec\") or put 'nxc'/'netexec' on PATH."
131
+ )
132
+
133
+
134
+ def _env_int(name: str, default: int) -> int:
135
+ raw = os.environ.get(name)
136
+ if raw is None or not raw.strip():
137
+ return default
138
+ try:
139
+ value = int(raw)
140
+ except ValueError as exc:
141
+ raise ConfigError(f"{name} must be an integer.") from exc
142
+ if value <= 0:
143
+ raise ConfigError(f"{name} must be a positive integer.")
144
+ return value
145
+
146
+
147
+ def _resolve_mode() -> tuple[str, bool, bool]:
148
+ """Resolve the operating mode -> (mode, dry_run, allow_offensive).
149
+
150
+ `NXC_MODE` (suggest|recon|full) is the canonical control; the `dry_run` and
151
+ `allow_offensive` booleans are derived from it. Defaults to `recon` when unset.
152
+ """
153
+ raw = os.environ.get("NXC_MODE")
154
+ mode = raw.strip().lower() if raw and raw.strip() else "recon"
155
+ if mode not in VALID_MODES:
156
+ raise ConfigError(
157
+ f"NXC_MODE must be one of {', '.join(VALID_MODES)}; got {raw!r}."
158
+ )
159
+ return mode, mode == "suggest", mode == "full"
160
+
161
+
162
+ def _resolve_tool_mode() -> str:
163
+ """Resolve NXC_TOOL_MODE (static|dynamic); defaults to dynamic (progressive disclosure
164
+ -- usable on small-context models, cheaper on large ones; set static to opt out)."""
165
+ raw = (os.environ.get("NXC_TOOL_MODE") or "dynamic").strip().lower()
166
+ if raw not in VALID_TOOL_MODES:
167
+ raise ConfigError(
168
+ f"NXC_TOOL_MODE must be one of {', '.join(VALID_TOOL_MODES)}; got {raw!r}."
169
+ )
170
+ return raw
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class Config:
175
+ base_command: list[str]
176
+ protocols: list[str]
177
+ scope: list[str]
178
+ allow_offensive: bool
179
+ dry_run: bool
180
+ timeout: int
181
+ max_targets: int
182
+ audit_log: str | None
183
+ workspace: str | None
184
+ mode: str = "recon"
185
+ # loot mode permits credential-dumping (offensive but read-only) on top of recon;
186
+ # full implies loot. Derived from `mode` in from_env(); see check_offensive().
187
+ allow_loot: bool = False
188
+ # Tool-surface presentation (NXC_TOOL_MODE), orthogonal to `mode`. Default "dynamic"
189
+ # hides the full tool surface behind meta-tools so small-context models can use the
190
+ # server (see dynamic.py); "static" opts out to listing every tool.
191
+ tool_mode: str = "dynamic"
192
+ # From nxc.conf's `[nxc] pwn3d_label` (operator-configurable). Surfaced to the
193
+ # agent via the auth guide; the MCP itself keys auth on the `[+]` marker, which
194
+ # nxc does not make configurable.
195
+ pwn3d_label: str = "Pwn3d!"
196
+
197
+ @classmethod
198
+ def from_env(cls) -> "Config":
199
+ mode, dry_run, allow_offensive = _resolve_mode()
200
+ conf = read_nxc_conf()
201
+ return cls(
202
+ base_command=resolve_base_command(),
203
+ protocols=_env_list("NXC_PROTOCOLS") or list(DEFAULT_PROTOCOLS),
204
+ scope=_env_list("NXC_SCOPE"),
205
+ allow_offensive=allow_offensive,
206
+ # loot and full both permit credential-dumping; full additionally permits
207
+ # state-changing/offensive actions (allow_offensive).
208
+ allow_loot=mode in ("loot", "full"),
209
+ dry_run=dry_run,
210
+ timeout=_env_int("NXC_TIMEOUT", 300),
211
+ max_targets=_env_int("NXC_MAX_TARGETS", 256),
212
+ audit_log=os.environ.get("NXC_AUDIT_LOG"),
213
+ # NXC_WORKSPACE wins; else default to whatever nxc.conf writes to, so
214
+ # our reads track nxc's writes without extra configuration.
215
+ workspace=os.environ.get("NXC_WORKSPACE") or conf["workspace"],
216
+ mode=mode,
217
+ tool_mode=_resolve_tool_mode(),
218
+ pwn3d_label=conf["pwn3d_label"],
219
+ )