Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion capabilities/network-ops/capability.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema: 1
name: network-ops
version: "2.1.10"
version: "2.1.11"
description: >
Network operations and Active Directory exploitation. Multi-agent
pipeline for autonomous red teaming with Nmap scanning, Netexec
Expand Down
121 changes: 121 additions & 0 deletions capabilities/network-ops/tests/test_tool_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,127 @@ async def test_certipy_find_builds_auth_correctly(self):
assert "-domain" not in cmd


# ===================================================================
# Certipy: uniform, hang-safe stdin confirmation across the toolset
# ===================================================================


class TestCertipyConfirmInput:
"""Every certipy_* tool should auto-confirm interactive prompts by default
and close stdin, so no sibling tool can hang on a prompt until timeout, and
an explicit `input` always overrides."""

# Assert the literal "y" the subprocess must receive, not Certipy._CONFIRM_INPUT:
# comparing to the module constant would stay green if someone set it to "" or
# None, which (via execute()'s `if proc.stdin and input:` guard) would stop
# closing stdin and reintroduce the very prompt-hang these tests guard against.
CONFIRM = "y"

def _make_certipy(self):
c = certipy_mod.Certipy()
c.certipy_cmd = "certipy"
c.timeout = 10
return c

def _input_kwarg(self, mock_exec):
"""Extract the `input` passed to execute, positional or keyword."""
assert mock_exec.await_count == 1
return mock_exec.call_args.kwargs["input"]

@pytest.mark.asyncio
async def test_direct_generic_tools_default_to_confirm(self):
"""account/auth/ca/cert/req/template send the confirm default to stdin
when no input is given (none inherit the parent's stdin)."""
for method in [
"certipy_account",
"certipy_auth",
"certipy_ca",
"certipy_cert",
"certipy_req",
"certipy_template",
]:
c = self._make_certipy()
with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "output"
await getattr(c, method)(args=[])
assert self._input_kwarg(mock_exec) == self.CONFIRM, f"{method} should auto-confirm by default"

@pytest.mark.asyncio
async def test_passthrough_and_find_default_to_confirm(self):
"""The generic passthrough and certipy_find route through certipy() and
inherit the same confirm default."""
c = self._make_certipy()
with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "output"
await c.certipy(action="find", args=[], target="10.0.0.1")
assert self._input_kwarg(mock_exec) == self.CONFIRM

c = self._make_certipy()
with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "output"
await c.certipy_find(target="10.0.0.1")
assert self._input_kwarg(mock_exec) == self.CONFIRM

@pytest.mark.asyncio
async def test_relay_does_not_receive_confirm_stdin(self):
"""The relay action is a streaming listener that never reads stdin; the
confirm default must not leak into it (it bypasses execute())."""
c = self._make_certipy()
with (
patch.object(certipy_mod, "stream_listener", new_callable=AsyncMock) as mock_relay,
patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec,
):
mock_relay.return_value = "output"
await c.certipy(action="relay", args=["-target", "http://ca"], target="1.2.3.4")
mock_exec.assert_not_awaited()
mock_relay.assert_awaited_once()

@pytest.mark.asyncio
async def test_high_level_tools_default_to_confirm(self):
"""The high-level helpers no longer hardcode stdin; they inherit the
uniform confirm default via certipy()."""
c = self._make_certipy()
with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "output"
await c.certipy_request_certificate(
target="10.0.0.1",
ca_server="ca.corp.local",
ca_name="corp-CA",
template="User",
username="Admin",
domain="corp.local",
)
assert self._input_kwarg(mock_exec) == self.CONFIRM

c = self._make_certipy()
with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "output"
await c.certipy_certificate_auth(pfx_file="admin.pfx", target="10.0.0.1")
assert self._input_kwarg(mock_exec) == self.CONFIRM

@pytest.mark.asyncio
async def test_explicit_input_overrides_default(self):
"""A caller-supplied non-empty input is passed through unchanged."""
c = self._make_certipy()
with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "output"
await c.certipy_cert(args=["-export"], input="n")
assert self._input_kwarg(mock_exec) == "n"

@pytest.mark.asyncio
async def test_empty_input_still_confirms(self):
"""An empty string falls back to the confirm default rather than being
forwarded as-is. This is deliberate: execute() only closes stdin when the
input is truthy, so forwarding "" would leave stdin open and reintroduce
the prompt-hang. Callers decline by sending an explicit answer (e.g. "n"),
not an empty string."""
c = self._make_certipy()
with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec:
mock_exec.return_value = "output"
await c.certipy_cert(args=["-export"], input="")
assert self._input_kwarg(mock_exec) == self.CONFIRM


# ===================================================================
# Impacket: _build_ntlmrelayx_args
# ===================================================================
Expand Down
67 changes: 48 additions & 19 deletions capabilities/network-ops/tools/certipy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@
"pkcs#12",
]

# Certipy's interactive subcommands prompt for confirmation (e.g. overwriting a
# saved PFX/ccache). Agents run non-interactively, so an unanswered prompt would
# block on stdin until the command hits its timeout. Every certipy_* tool passes
# this as the default stdin so behavior is uniform across the toolset and stdin
# is always closed (the prompt gets a definitive answer, never a hang). Callers
# pass an explicit `input` to answer differently (e.g. "n").
#
# This is exposed as the family-wide `input: str | None` stdin parameter rather
# than a dedicated `confirm: bool`, to stay uniform with the sibling tools
# (impacket, bloodyad, coercion, krbrelayx), which all take `input` and guard
# the same prompt-hang this way. The `relay` action is exempt: it is a streaming
# listener that never reads stdin.
_CONFIRM_INPUT = "y"


def _detect_certipy_command() -> str:
"""Detect whether certipy is available as 'certipy' (pip) or 'certipy-ad' (Kali)."""
Expand Down Expand Up @@ -108,7 +122,9 @@ async def certipy(
relay_timeout: Only for action="relay": seconds to keep the listener up
waiting for a relayed certificate before returning (defaults to the
toolset's relay_timeout). Ignored for every other action.
input: Optional input string to send to the command's standard input.
input: Optional stdin for the command. The default (and any empty
value) auto-confirms certipy's interactive prompts with "y";
pass an explicit answer such as "n" to respond differently.
Ignored for action="relay", which does not read from stdin.
"""
cmd = [self.certipy_cmd, action]
Expand Down Expand Up @@ -140,7 +156,7 @@ async def certipy(
env=None,
)

return await execute(cmd, timeout=self.timeout, input=input)
return await execute(cmd, timeout=self.timeout, input=input or _CONFIRM_INPUT)

# Specialized methods

Expand Down Expand Up @@ -220,7 +236,6 @@ async def certipy_request_certificate(
domain=domain,
password=password,
nt_hash=nt_hash,
input="y",
)

@tool_method(catch=True, variants=["specialized", "all"])
Expand All @@ -247,7 +262,6 @@ async def certipy_certificate_auth(
username=username,
domain=domain,
target=target,
input="y",
)

# General methods
Expand Down Expand Up @@ -318,12 +332,14 @@ async def certipy_account(self, args: list[str], input: str | None = None) -> st

Args:
args: List of arguments for the command.
input: Optional input string to pass to the command's stdin.
input: Optional stdin for the command. The default (and any empty
value) auto-confirms certipy's interactive prompts with "y";
pass an explicit answer such as "n" to respond differently.
"""
return await execute([self.certipy_cmd, "account", *args], timeout=self.timeout, input=input)
return await execute([self.certipy_cmd, "account", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT)

@tool_method(catch=True, variants=["generic", "all"])
async def certipy_auth(self, args: list[str], input: str | None = "y") -> str:
async def certipy_auth(self, args: list[str], input: str | None = None) -> str:
"""
Execute a certipy-ad auth command.

Expand Down Expand Up @@ -364,9 +380,11 @@ async def certipy_auth(self, args: list[str], input: str | None = "y") -> str:

Args:
args: List of arguments for the command.
input: Optional input string to pass to the command's stdin.
input: Optional stdin for the command. The default (and any empty
value) auto-confirms certipy's interactive prompts with "y";
pass an explicit answer such as "n" to respond differently.
"""
return await execute([self.certipy_cmd, "auth", *args], timeout=self.timeout, input=input)
return await execute([self.certipy_cmd, "auth", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT)

@tool_method(catch=True, variants=["generic", "all"])
async def certipy_ca(self, args: list[str], input: str | None = None) -> str:
Expand Down Expand Up @@ -447,12 +465,14 @@ async def certipy_ca(self, args: list[str], input: str | None = None) -> str:

Args:
args: List of arguments for the command.
input: Optional input string to pass to the command's stdin.
input: Optional stdin for the command. The default (and any empty
value) auto-confirms certipy's interactive prompts with "y";
pass an explicit answer such as "n" to respond differently.
"""
return await execute([self.certipy_cmd, "ca", *args], timeout=self.timeout, input=input)
return await execute([self.certipy_cmd, "ca", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT)

@tool_method(catch=True, variants=["generic", "all"])
async def certipy_cert(self, args: list[str], input: str | None = "y") -> str:
async def certipy_cert(self, args: list[str], input: str | None = None) -> str:
r"""
Execute a certipy-ad cert command.

Expand All @@ -479,9 +499,11 @@ async def certipy_cert(self, args: list[str], input: str | None = "y") -> str:

Args:
args: List of arguments for the command.
input: Optional input string to pass to the command's stdin.
input: Optional stdin for the command. The default (and any empty
value) auto-confirms certipy's interactive prompts with "y";
pass an explicit answer such as "n" to respond differently.
"""
return await execute([self.certipy_cmd, "cert", *args], timeout=self.timeout, input=input)
return await execute([self.certipy_cmd, "cert", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT)

@tool_method(catch=True, variants=["generic", "all"])
async def certipy_find(
Expand Down Expand Up @@ -539,7 +561,9 @@ async def certipy_find(
domain: The domain name (combined with username as user@domain).
password: The password for authentication.
nt_hash: The NTLM hash for authentication.
input: Optional input string to pass to the command.
input: Optional stdin for the command. The default (and any empty
value) auto-confirms certipy's interactive prompts with "y";
pass an explicit answer such as "n" to respond differently.
"""
return await self.certipy(
action="find",
Expand All @@ -553,7 +577,7 @@ async def certipy_find(
)

@tool_method(catch=True, variants=["generic", "all"])
async def certipy_req(self, args: list[str], input: str | None = "y") -> str:
async def certipy_req(self, args: list[str], input: str | None = None) -> str:
r"""
Execute a certipy-ad req command.

Expand Down Expand Up @@ -643,8 +667,11 @@ async def certipy_req(self, args: list[str], input: str | None = "y") -> str:

Args:
args: List of arguments for the command.
input: Optional stdin for the command. The default (and any empty
value) auto-confirms certipy's interactive prompts with "y";
pass an explicit answer such as "n" to respond differently.
"""
return await execute([self.certipy_cmd, "req", *args], timeout=self.timeout, input=input)
return await execute([self.certipy_cmd, "req", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT)

@tool_method(catch=True, variants=["generic", "all"])
async def certipy_template(self, args: list[str], input: str | None = None) -> str:
Expand Down Expand Up @@ -713,6 +740,8 @@ async def certipy_template(self, args: list[str], input: str | None = None) -> s

Args:
args: List of arguments for the command.
input: Optional input string to pass to the command's stdin.
input: Optional stdin for the command. The default (and any empty
value) auto-confirms certipy's interactive prompts with "y";
pass an explicit answer such as "n" to respond differently.
"""
return await execute([self.certipy_cmd, "template", *args], timeout=self.timeout, input=input)
return await execute([self.certipy_cmd, "template", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT)
Loading