pypsrp 0.8.1__py3-none-any.whl → 0.9.0rc2__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.
- pypsrp/_pool_manager.py +142 -0
- pypsrp/_utils.py +0 -1
- pypsrp/client.py +0 -1
- pypsrp/complex_objects.py +3 -3
- pypsrp/encryption.py +0 -1
- pypsrp/messages.py +3 -2
- pypsrp/negotiate.py +5 -8
- pypsrp/powershell.py +109 -8
- pypsrp/serializer.py +21 -18
- pypsrp/shell.py +41 -3
- pypsrp/wsman.py +118 -40
- {pypsrp-0.8.1.dist-info → pypsrp-0.9.0rc2.dist-info}/METADATA +73 -34
- pypsrp-0.9.0rc2.dist-info/RECORD +23 -0
- {pypsrp-0.8.1.dist-info → pypsrp-0.9.0rc2.dist-info}/WHEEL +1 -1
- pypsrp-0.8.1.dist-info/RECORD +0 -23
- pypsrp-0.8.1.dist-info/zip-safe +0 -1
- {pypsrp-0.8.1.dist-info → pypsrp-0.9.0rc2.dist-info/licenses}/LICENSE +0 -0
- {pypsrp-0.8.1.dist-info → pypsrp-0.9.0rc2.dist-info}/top_level.txt +0 -0
pypsrp/_pool_manager.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Copyright: (c) 2026, Jordan Borean (@jborean93) <jborean93@gmail.com>
|
|
2
|
+
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextvars
|
|
7
|
+
import functools
|
|
8
|
+
import types
|
|
9
|
+
import typing as t
|
|
10
|
+
|
|
11
|
+
import requests.adapters
|
|
12
|
+
from requests.packages.urllib3.util.retry import Retry
|
|
13
|
+
|
|
14
|
+
T = t.TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NewConnectionDisabled(Exception):
|
|
18
|
+
"""Raised when new connections are being made but have been disabled in the current context."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DisableNewConnectionsContext:
|
|
22
|
+
"""Context manager to disable new connections from being created."""
|
|
23
|
+
|
|
24
|
+
__slots__ = "_contextvar_token"
|
|
25
|
+
|
|
26
|
+
_contextvar: t.ClassVar[contextvars.ContextVar] = contextvars.ContextVar("NewConnectionContext")
|
|
27
|
+
_contextvar_token: contextvars.Token
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def current(cls) -> DisableNewConnectionsContext | None:
|
|
31
|
+
"""Returns the current context or None if not set."""
|
|
32
|
+
try:
|
|
33
|
+
return cls._contextvar.get()
|
|
34
|
+
except LookupError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def __enter__(self) -> DisableNewConnectionsContext:
|
|
38
|
+
self._contextvar_token = self.__class__._contextvar.set(self)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def __exit__(
|
|
42
|
+
self,
|
|
43
|
+
exc_type: type[BaseException] | None,
|
|
44
|
+
exc_val: BaseException | None,
|
|
45
|
+
exc_tb: types.TracebackType | None,
|
|
46
|
+
) -> bool | None:
|
|
47
|
+
self.__class__._contextvar.reset(self._contextvar_token)
|
|
48
|
+
del self._contextvar_token
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HTTPSAdapterWithKeyPassword(requests.adapters.HTTPAdapter):
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*args: t.Any,
|
|
58
|
+
_pypsrp_key_password: str | None = None,
|
|
59
|
+
**kwargs: t.Any,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.__key_password = _pypsrp_key_password
|
|
62
|
+
super().__init__(*args, **kwargs)
|
|
63
|
+
|
|
64
|
+
def init_poolmanager(
|
|
65
|
+
self,
|
|
66
|
+
connections,
|
|
67
|
+
maxsize,
|
|
68
|
+
block=False,
|
|
69
|
+
**pool_kwargs,
|
|
70
|
+
):
|
|
71
|
+
return super().init_poolmanager(
|
|
72
|
+
connections,
|
|
73
|
+
maxsize,
|
|
74
|
+
block,
|
|
75
|
+
key_password=self.__key_password,
|
|
76
|
+
**pool_kwargs,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _wrap(
|
|
81
|
+
func: t.Callable[..., T],
|
|
82
|
+
*,
|
|
83
|
+
before: t.Callable[[], None] | None = None,
|
|
84
|
+
after: t.Callable[[T], None] | None = None,
|
|
85
|
+
) -> t.Callable[..., T]:
|
|
86
|
+
"""Wraps a function with before and after callables."""
|
|
87
|
+
|
|
88
|
+
@functools.wraps(func)
|
|
89
|
+
def wrapper(*args: t.Any, **kwargs: t.Any) -> T:
|
|
90
|
+
if before:
|
|
91
|
+
before()
|
|
92
|
+
|
|
93
|
+
res = func(*args, **kwargs)
|
|
94
|
+
|
|
95
|
+
if after:
|
|
96
|
+
after(res)
|
|
97
|
+
|
|
98
|
+
return res
|
|
99
|
+
|
|
100
|
+
return wrapper
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _raise_if_connections_disabled():
|
|
104
|
+
if DisableNewConnectionsContext.current() is not None:
|
|
105
|
+
raise NewConnectionDisabled()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _create_new_connection_with_failure(connection):
|
|
109
|
+
connection.connect = _wrap(connection.connect, before=_raise_if_connections_disabled)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _create_connection_pool(pool):
|
|
113
|
+
# This is called in the urllib3 pool when a new connection is needed. We
|
|
114
|
+
# wrap it so we can wrap the connect method when a new connection is
|
|
115
|
+
# created.
|
|
116
|
+
pool.ConnectionCls = _wrap(pool.ConnectionCls, after=_create_new_connection_with_failure)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def create_request_adapter( # type: ignore[no-any-unimported] # requests does not have typing stubs for urllib3
|
|
120
|
+
*,
|
|
121
|
+
max_retries: Retry,
|
|
122
|
+
key_password: str | None = None,
|
|
123
|
+
) -> requests.adapters.HTTPAdapter:
|
|
124
|
+
"""Creates a HTTPAdapter with support for disabling new connections via context."""
|
|
125
|
+
adapter: requests.adapters.HTTPAdapter
|
|
126
|
+
if key_password is not None:
|
|
127
|
+
adapter = HTTPSAdapterWithKeyPassword(
|
|
128
|
+
max_retries=max_retries,
|
|
129
|
+
_pypsrp_key_password=key_password,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
adapter = requests.adapters.HTTPAdapter(max_retries=max_retries)
|
|
133
|
+
|
|
134
|
+
# pool_classes_by_scheme stores the urllib3 pool types used when creating
|
|
135
|
+
# the connection pool for http/https. We wrap the __init__ method so we can
|
|
136
|
+
# inject our custom ConnectionCls that wraps the connect method when
|
|
137
|
+
# created.
|
|
138
|
+
pool_classes = adapter.poolmanager.pool_classes_by_scheme
|
|
139
|
+
for scheme, pool_cls in list(pool_classes.items()):
|
|
140
|
+
pool_classes[scheme] = _wrap(pool_cls, after=_create_connection_pool)
|
|
141
|
+
|
|
142
|
+
return adapter
|
pypsrp/_utils.py
CHANGED
pypsrp/client.py
CHANGED
|
@@ -15,7 +15,6 @@ import warnings
|
|
|
15
15
|
import xml.etree.ElementTree as ET
|
|
16
16
|
|
|
17
17
|
from pypsrp._utils import get_pwsh_script, to_bytes, to_unicode
|
|
18
|
-
from pypsrp.complex_objects import ComplexObject
|
|
19
18
|
from pypsrp.exceptions import WinRMError
|
|
20
19
|
from pypsrp.powershell import (
|
|
21
20
|
DEFAULT_CONFIGURATION_NAME,
|
pypsrp/complex_objects.py
CHANGED
|
@@ -139,7 +139,7 @@ class Enum(ComplexObject):
|
|
|
139
139
|
self.value = kwargs.get("value")
|
|
140
140
|
|
|
141
141
|
@property # type: ignore[override]
|
|
142
|
-
def _to_string(self) -> str:
|
|
142
|
+
def _to_string(self) -> str:
|
|
143
143
|
try:
|
|
144
144
|
return self._string_map[self.value or 0]
|
|
145
145
|
except KeyError as err:
|
|
@@ -378,7 +378,7 @@ class RemoteStreamOptions(Enum):
|
|
|
378
378
|
)
|
|
379
379
|
|
|
380
380
|
@property # type: ignore[override]
|
|
381
|
-
def _to_string(self) -> str:
|
|
381
|
+
def _to_string(self) -> str:
|
|
382
382
|
if self.value == 15:
|
|
383
383
|
return "AddInvocationInfo"
|
|
384
384
|
|
|
@@ -1149,7 +1149,7 @@ class CommandType(Enum):
|
|
|
1149
1149
|
super(CommandType, self).__init__("System.Management.Automation.CommandTypes", {}, **kwargs)
|
|
1150
1150
|
|
|
1151
1151
|
@property # type: ignore[override]
|
|
1152
|
-
def _to_string(self) -> str:
|
|
1152
|
+
def _to_string(self) -> str:
|
|
1153
1153
|
if self.value == 0x01FF:
|
|
1154
1154
|
return "All"
|
|
1155
1155
|
|
pypsrp/encryption.py
CHANGED
pypsrp/messages.py
CHANGED
|
@@ -127,7 +127,8 @@ class Message(object):
|
|
|
127
127
|
):
|
|
128
128
|
msg = ET.Element("S")
|
|
129
129
|
else:
|
|
130
|
-
|
|
130
|
+
serialized_msg = self._serializer.serialize(self.data)
|
|
131
|
+
msg = serialized_msg if serialized_msg is not None else b""
|
|
131
132
|
|
|
132
133
|
if not isinstance(msg, bytes):
|
|
133
134
|
message_data = ET.tostring(msg, encoding="utf-8", method="xml")
|
|
@@ -155,7 +156,7 @@ class Message(object):
|
|
|
155
156
|
rpid = str(uuid.UUID(bytes_le=data[8:24]))
|
|
156
157
|
pid = str(uuid.UUID(bytes_le=data[24:40]))
|
|
157
158
|
|
|
158
|
-
if data[40:43] == b"\
|
|
159
|
+
if data[40:43] == b"\xef\xbb\xbf":
|
|
159
160
|
# 40-43 is the UTF-8 BOM which we don't care about
|
|
160
161
|
message_data = to_string(data[43:])
|
|
161
162
|
else:
|
pypsrp/negotiate.py
CHANGED
|
@@ -11,7 +11,6 @@ import spnego
|
|
|
11
11
|
import spnego.channel_bindings
|
|
12
12
|
from cryptography import x509
|
|
13
13
|
from cryptography.exceptions import UnsupportedAlgorithm
|
|
14
|
-
from cryptography.hazmat.backends import default_backend
|
|
15
14
|
from cryptography.hazmat.primitives import hashes
|
|
16
15
|
from requests.auth import AuthBase
|
|
17
16
|
from requests.packages.urllib3.response import HTTPResponse
|
|
@@ -37,7 +36,7 @@ class HTTPNegotiateAuth(AuthBase):
|
|
|
37
36
|
password: typing.Optional[str] = None,
|
|
38
37
|
auth_provider: str = "negotiate",
|
|
39
38
|
send_cbt: bool = True,
|
|
40
|
-
service: str = "
|
|
39
|
+
service: str = "host",
|
|
41
40
|
delegate: bool = False,
|
|
42
41
|
hostname_override: typing.Optional[str] = None,
|
|
43
42
|
wrap_required: bool = False,
|
|
@@ -61,7 +60,7 @@ class HTTPNegotiateAuth(AuthBase):
|
|
|
61
60
|
:param send_cbt: Try to bind the channel token (HTTPS only) to the auth
|
|
62
61
|
process, default is True
|
|
63
62
|
:param service: The service part of the SPN to authenticate with,
|
|
64
|
-
defaults to
|
|
63
|
+
defaults to host
|
|
65
64
|
:param delegate: Whether to get an auth token that allows the token to
|
|
66
65
|
be delegated to other servers, this is only used with Kerberos and
|
|
67
66
|
defaults to False
|
|
@@ -257,9 +256,7 @@ class HTTPNegotiateAuth(AuthBase):
|
|
|
257
256
|
:return: The byte string containing the hash of the server's
|
|
258
257
|
certificate
|
|
259
258
|
"""
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
cert = x509.load_der_x509_certificate(certificate_der, backend)
|
|
259
|
+
cert = x509.load_der_x509_certificate(certificate_der)
|
|
263
260
|
|
|
264
261
|
hash_algorithm = None
|
|
265
262
|
try:
|
|
@@ -273,9 +270,9 @@ class HTTPNegotiateAuth(AuthBase):
|
|
|
273
270
|
# If the cert signature algorithm is unknown, md5, or sha1 then use sha256 otherwise use the signature
|
|
274
271
|
# algorithm of the cert itself.
|
|
275
272
|
if not hash_algorithm or hash_algorithm.name in ["md5", "sha1"]:
|
|
276
|
-
digest = hashes.Hash(hashes.SHA256()
|
|
273
|
+
digest = hashes.Hash(hashes.SHA256())
|
|
277
274
|
else:
|
|
278
|
-
digest = hashes.Hash(hash_algorithm
|
|
275
|
+
digest = hashes.Hash(hash_algorithm)
|
|
279
276
|
|
|
280
277
|
digest.update(certificate_der)
|
|
281
278
|
certificate_hash = digest.finalize()
|
pypsrp/powershell.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Copyright: (c) 2018, Jordan Borean (@jborean93) <jborean93@gmail.com>
|
|
2
2
|
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
|
|
3
3
|
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
4
6
|
import base64
|
|
5
7
|
import logging
|
|
6
8
|
import struct
|
|
@@ -11,10 +13,11 @@ import uuid
|
|
|
11
13
|
import warnings
|
|
12
14
|
import xml.etree.ElementTree as ET
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
import requests.exceptions
|
|
15
17
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
16
18
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
17
19
|
|
|
20
|
+
from pypsrp import _pool_manager
|
|
18
21
|
from pypsrp._utils import version_equal_or_newer
|
|
19
22
|
from pypsrp.complex_objects import (
|
|
20
23
|
ApartmentState,
|
|
@@ -103,6 +106,9 @@ class RunspacePool(object):
|
|
|
103
106
|
min_runspaces: int = 1,
|
|
104
107
|
max_runspaces: int = 1,
|
|
105
108
|
session_key_timeout_ms: int = 60000,
|
|
109
|
+
*,
|
|
110
|
+
no_profile: bool = False,
|
|
111
|
+
idle_timeout: int | None = None,
|
|
106
112
|
) -> None:
|
|
107
113
|
"""
|
|
108
114
|
Represents a Runspace pool on a remote host. This pool can contain
|
|
@@ -129,6 +135,11 @@ class RunspacePool(object):
|
|
|
129
135
|
hold
|
|
130
136
|
:param session_key_timeout_ms: The maximum time to wait for a session
|
|
131
137
|
key transfer from the server
|
|
138
|
+
:param no_profile: If True, the user profile will not be loaded and
|
|
139
|
+
will use the machine defaults.
|
|
140
|
+
:param idle_timeout: The idle timeout, in seconds, for the runspace. The
|
|
141
|
+
runspace will be closed if no operations are performed in the time
|
|
142
|
+
specified. If None the server default will be used.
|
|
132
143
|
"""
|
|
133
144
|
log.info("Initialising RunspacePool object for configuration %s" % configuration_name)
|
|
134
145
|
# The below are defined in some way at
|
|
@@ -138,7 +149,13 @@ class RunspacePool(object):
|
|
|
138
149
|
self.connection = connection
|
|
139
150
|
resource_uri = "http://schemas.microsoft.com/powershell/%s" % configuration_name
|
|
140
151
|
self.shell = WinRS(
|
|
141
|
-
connection,
|
|
152
|
+
connection,
|
|
153
|
+
resource_uri=resource_uri,
|
|
154
|
+
id=self.id,
|
|
155
|
+
input_streams="stdin pr",
|
|
156
|
+
output_streams="stdout",
|
|
157
|
+
no_profile=no_profile,
|
|
158
|
+
idle_time_out=idle_timeout,
|
|
142
159
|
)
|
|
143
160
|
self.ci_table: typing.Dict = {}
|
|
144
161
|
self.pipelines: typing.Dict[str, "PowerShell"] = {}
|
|
@@ -569,7 +586,6 @@ class RunspacePool(object):
|
|
|
569
586
|
self._exchange_key = rsa.generate_private_key(
|
|
570
587
|
public_exponent=65537,
|
|
571
588
|
key_size=2048,
|
|
572
|
-
backend=default_backend(),
|
|
573
589
|
)
|
|
574
590
|
public_numbers = self._exchange_key.public_key().public_numbers()
|
|
575
591
|
exponent = struct.pack("<I", public_numbers.e)
|
|
@@ -652,6 +668,47 @@ class RunspacePool(object):
|
|
|
652
668
|
"""
|
|
653
669
|
return self._serializer.serialize(obj, metadata=metadata)
|
|
654
670
|
|
|
671
|
+
def is_alive(
|
|
672
|
+
self,
|
|
673
|
+
*,
|
|
674
|
+
timeout: int | None = None,
|
|
675
|
+
) -> bool:
|
|
676
|
+
"""Checks whether the RunspacePool is still alive.
|
|
677
|
+
|
|
678
|
+
:param timeout: Override the connection timeout defaults for the
|
|
679
|
+
request. The WSMan operation timeout will be set to this value
|
|
680
|
+
and the HTTP connect/read timeouts will be this value + 2 seconds.
|
|
681
|
+
:return: A bool True if the pool is still alive, False otherwise.
|
|
682
|
+
"""
|
|
683
|
+
is_closed = False
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
# We don't want to try and open a new connection if the socket
|
|
687
|
+
# was closed, we treat it as the pool is not alive.
|
|
688
|
+
with _pool_manager.DisableNewConnectionsContext():
|
|
689
|
+
state = self.shell._get_shell(timeout=timeout)
|
|
690
|
+
|
|
691
|
+
is_closed = state.get("State", "") == "Disconnected"
|
|
692
|
+
except WSManFaultError as exc:
|
|
693
|
+
if exc.code in [
|
|
694
|
+
0x80338029, # ERROR_WSMAN_OPERATION_TIMEDOUT
|
|
695
|
+
0x8033805B, # ERROR_WSMAN_UNEXPECTED_SELECTORS
|
|
696
|
+
]:
|
|
697
|
+
is_closed = True
|
|
698
|
+
else:
|
|
699
|
+
raise
|
|
700
|
+
|
|
701
|
+
except (_pool_manager.NewConnectionDisabled, TimeoutError, requests.exceptions.ConnectionError):
|
|
702
|
+
# If a timeout or connection error occurs, treat the pool as closed.
|
|
703
|
+
is_closed = True
|
|
704
|
+
|
|
705
|
+
if is_closed:
|
|
706
|
+
# Ensures that close() doesn't try and close an already closed pool
|
|
707
|
+
self.state = RunspacePoolState.CLOSED
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
return True
|
|
711
|
+
|
|
655
712
|
def _receive(
|
|
656
713
|
self,
|
|
657
714
|
id: typing.Optional[str] = None,
|
|
@@ -861,7 +918,7 @@ class RunspacePool(object):
|
|
|
861
918
|
iv = b"\x00" * 16 # PSRP doesn't use an IV
|
|
862
919
|
algorithm = algorithms.AES(decrypted_key)
|
|
863
920
|
mode = modes.CBC(iv)
|
|
864
|
-
cipher = Cipher(algorithm, mode
|
|
921
|
+
cipher = Cipher(algorithm, mode)
|
|
865
922
|
|
|
866
923
|
self._serializer.cipher = cipher
|
|
867
924
|
self._key_exchanged = True
|
|
@@ -907,7 +964,16 @@ class PowerShell(object):
|
|
|
907
964
|
# CommandID that is created and we need to reference in the WSMan msgs
|
|
908
965
|
self._command_id: typing.Optional[str] = None
|
|
909
966
|
|
|
910
|
-
|
|
967
|
+
def __enter__(self) -> "PowerShell":
|
|
968
|
+
return self
|
|
969
|
+
|
|
970
|
+
def __exit__(
|
|
971
|
+
self,
|
|
972
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
973
|
+
value: typing.Optional[BaseException],
|
|
974
|
+
traceback: typing.Optional[types.TracebackType],
|
|
975
|
+
) -> None:
|
|
976
|
+
self.close()
|
|
911
977
|
|
|
912
978
|
def add_argument(
|
|
913
979
|
self,
|
|
@@ -1042,6 +1108,33 @@ class PowerShell(object):
|
|
|
1042
1108
|
self.commands = []
|
|
1043
1109
|
return self
|
|
1044
1110
|
|
|
1111
|
+
def clear_streams(self) -> None:
|
|
1112
|
+
"""
|
|
1113
|
+
Clears all the data streams of the current PowerShell object.
|
|
1114
|
+
"""
|
|
1115
|
+
self.streams = PSDataStreams()
|
|
1116
|
+
self.output = []
|
|
1117
|
+
|
|
1118
|
+
def close(self) -> None:
|
|
1119
|
+
"""
|
|
1120
|
+
Closes the PowerShell pipeline and cleans up server resources.
|
|
1121
|
+
|
|
1122
|
+
This method must be called to reuse a PowerShell object for multiple
|
|
1123
|
+
invocations. It sends a TERMINATE signal to the server and removes
|
|
1124
|
+
the pipeline from the runspace pool's pipeline registry.
|
|
1125
|
+
|
|
1126
|
+
:raises InvalidPipelineStateError: If the pipeline is not in a terminal
|
|
1127
|
+
state (COMPLETED, STOPPED, or FAILED)
|
|
1128
|
+
"""
|
|
1129
|
+
valid_states = [PSInvocationState.COMPLETED, PSInvocationState.STOPPED, PSInvocationState.FAILED]
|
|
1130
|
+
if self.state == PSInvocationState.NOT_STARTED:
|
|
1131
|
+
return
|
|
1132
|
+
elif self.state not in valid_states:
|
|
1133
|
+
raise InvalidPipelineStateError(self.state, valid_states, "close a PowerShell pipeline")
|
|
1134
|
+
|
|
1135
|
+
self.runspace_pool.shell.signal(SignalCode.TERMINATE, command_id=self._command_id or self.id)
|
|
1136
|
+
self.runspace_pool.pipelines.pop(self.id, None)
|
|
1137
|
+
|
|
1045
1138
|
def connect(self):
|
|
1046
1139
|
"""
|
|
1047
1140
|
Connects to a running command on a remote server, waits until the
|
|
@@ -1124,8 +1217,14 @@ class PowerShell(object):
|
|
|
1124
1217
|
values. Will default to returning the invocation info on all
|
|
1125
1218
|
"""
|
|
1126
1219
|
log.info("Beginning remote Pipeline invocation")
|
|
1127
|
-
|
|
1128
|
-
|
|
1220
|
+
valid_states = [
|
|
1221
|
+
PSInvocationState.NOT_STARTED,
|
|
1222
|
+
PSInvocationState.STOPPED,
|
|
1223
|
+
PSInvocationState.COMPLETED,
|
|
1224
|
+
PSInvocationState.FAILED,
|
|
1225
|
+
]
|
|
1226
|
+
if self.state not in valid_states:
|
|
1227
|
+
raise InvalidPipelineStateError(self.state, valid_states, "start a PowerShell pipeline")
|
|
1129
1228
|
|
|
1130
1229
|
if len(self.commands) == 0:
|
|
1131
1230
|
raise InvalidPSRPOperation("Cannot invoke PowerShell without any commands being set")
|
|
@@ -1414,7 +1513,7 @@ class PowerShell(object):
|
|
|
1414
1513
|
self.state = PSInvocationState.STOPPING
|
|
1415
1514
|
self.runspace_pool.shell.signal(SignalCode.PS_CTRL_C, str(self.id).upper())
|
|
1416
1515
|
self.state = PSInvocationState.STOPPED
|
|
1417
|
-
|
|
1516
|
+
self.runspace_pool.pipelines.pop(self.id, None)
|
|
1418
1517
|
|
|
1419
1518
|
def _invoke(
|
|
1420
1519
|
self,
|
|
@@ -1422,6 +1521,8 @@ class PowerShell(object):
|
|
|
1422
1521
|
) -> None:
|
|
1423
1522
|
fragments = self.runspace_pool._fragmenter.fragment(msg, self.runspace_pool.id, self.id)
|
|
1424
1523
|
|
|
1524
|
+
self.runspace_pool.pipelines[self.id] = self
|
|
1525
|
+
|
|
1425
1526
|
# send first fragment as Command message
|
|
1426
1527
|
first_frag = base64.b64encode(fragments.pop(0)).decode("utf-8")
|
|
1427
1528
|
resp = self.runspace_pool.shell.command("", arguments=[first_frag], command_id=self.id)
|
pypsrp/serializer.py
CHANGED
|
@@ -62,7 +62,7 @@ class Serializer(object):
|
|
|
62
62
|
self.cipher: typing.Any = None
|
|
63
63
|
# Finds C0, C1 and surrogate pairs in a unicode string for us to
|
|
64
64
|
# encode according to the PSRP rules
|
|
65
|
-
self._serial_str = re.compile("[\u0000-\
|
|
65
|
+
self._serial_str = re.compile("[\u0000-\u001f\u007f-\u009f\U00010000-\U0010ffff]")
|
|
66
66
|
|
|
67
67
|
# to support surrogate UTF-16 pairs we need to use a UTF-16 regex
|
|
68
68
|
# so we can replace the UTF-16 string representation with the actual
|
|
@@ -615,7 +615,7 @@ class Serializer(object):
|
|
|
615
615
|
element: ET.Element,
|
|
616
616
|
metadata: ObjectMeta,
|
|
617
617
|
) -> typing.Any:
|
|
618
|
-
obj = metadata.object()
|
|
618
|
+
obj = metadata.object()
|
|
619
619
|
self.obj[element.attrib["RefId"]] = obj
|
|
620
620
|
|
|
621
621
|
to_string_value = element.find("ToString")
|
|
@@ -669,7 +669,7 @@ class Serializer(object):
|
|
|
669
669
|
element: ET.Element,
|
|
670
670
|
metadata: ObjectMeta,
|
|
671
671
|
) -> typing.Any:
|
|
672
|
-
obj = metadata.object()
|
|
672
|
+
obj = metadata.object()
|
|
673
673
|
self.obj[element.attrib["RefId"]] = obj
|
|
674
674
|
|
|
675
675
|
for obj_property in element:
|
|
@@ -703,13 +703,9 @@ class Serializer(object):
|
|
|
703
703
|
element: ET.Element,
|
|
704
704
|
metadata: typing.Optional[ObjectMeta] = None,
|
|
705
705
|
) -> typing.List:
|
|
706
|
-
list_value = []
|
|
707
706
|
value_meta = getattr(metadata, "list_value_meta", None)
|
|
708
707
|
|
|
709
|
-
|
|
710
|
-
for entry in entries or []:
|
|
711
|
-
entry_value = self.deserialize(entry, value_meta, clear=False)
|
|
712
|
-
list_value.append(entry_value)
|
|
708
|
+
list_value = list(self._deserialize_list_values(element, "LST", value_meta=value_meta))
|
|
713
709
|
|
|
714
710
|
return list_value
|
|
715
711
|
|
|
@@ -719,10 +715,8 @@ class Serializer(object):
|
|
|
719
715
|
) -> Queue:
|
|
720
716
|
queue: Queue = Queue()
|
|
721
717
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
entry_value = self.deserialize(entry, clear=False)
|
|
725
|
-
queue.put(entry_value)
|
|
718
|
+
for entry in self._deserialize_list_values(element, "QUE"):
|
|
719
|
+
queue.put(entry)
|
|
726
720
|
|
|
727
721
|
return queue
|
|
728
722
|
|
|
@@ -731,15 +725,24 @@ class Serializer(object):
|
|
|
731
725
|
element: ET.Element,
|
|
732
726
|
) -> typing.List:
|
|
733
727
|
# no native Stack object in Python so just use a list
|
|
734
|
-
stack =
|
|
735
|
-
|
|
736
|
-
entries = element.find("STK")
|
|
737
|
-
for entry in entries or []:
|
|
738
|
-
entry_value = self.deserialize(entry, clear=False)
|
|
739
|
-
stack.append(entry_value)
|
|
728
|
+
stack = list(self._deserialize_list_values(element, "STK"))
|
|
740
729
|
|
|
741
730
|
return stack
|
|
742
731
|
|
|
732
|
+
def _deserialize_list_values(
|
|
733
|
+
self,
|
|
734
|
+
element: ET.Element,
|
|
735
|
+
list_type: str,
|
|
736
|
+
value_meta: typing.Optional[ObjectMeta] = None,
|
|
737
|
+
) -> typing.Iterable[typing.Any]:
|
|
738
|
+
entries = element.find(list_type)
|
|
739
|
+
if entries is None:
|
|
740
|
+
return
|
|
741
|
+
|
|
742
|
+
for entry in entries:
|
|
743
|
+
entry_value = self.deserialize(entry, metadata=value_meta, clear=False)
|
|
744
|
+
yield entry_value
|
|
745
|
+
|
|
743
746
|
def _deserialize_dct(
|
|
744
747
|
self,
|
|
745
748
|
element: ET.Element,
|
pypsrp/shell.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Copyright: (c) 2018, Jordan Borean (@jborean93) <jborean93@gmail.com>
|
|
2
2
|
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
|
|
3
3
|
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
4
6
|
import base64
|
|
5
7
|
import logging
|
|
6
8
|
import types
|
|
@@ -67,7 +69,7 @@ class WinRS(object):
|
|
|
67
69
|
:param lifetime: The total lifetime of the shell
|
|
68
70
|
:param name: The name (description) of the shell
|
|
69
71
|
:param no_profile: Whether to create the shell with the user profile
|
|
70
|
-
active or not
|
|
72
|
+
active or not. This may not work on newer hosts like Server 2012+.
|
|
71
73
|
:param working_directory: The default working directory of the created
|
|
72
74
|
shell
|
|
73
75
|
"""
|
|
@@ -197,8 +199,8 @@ class WinRS(object):
|
|
|
197
199
|
# inherit the base options if it was passed in, otherwise use an empty
|
|
198
200
|
# option set
|
|
199
201
|
options = OptionSet() if base_options is None else base_options
|
|
200
|
-
if self.no_profile
|
|
201
|
-
options.add_option("WINRS_NOPROFILE",
|
|
202
|
+
if self.no_profile:
|
|
203
|
+
options.add_option("WINRS_NOPROFILE", "TRUE", {"MustComply": "true"})
|
|
202
204
|
if self.codepage is not None:
|
|
203
205
|
options.add_option("WINRS_CODEPAGE", str(self.codepage))
|
|
204
206
|
|
|
@@ -315,6 +317,42 @@ class WinRS(object):
|
|
|
315
317
|
ET.SubElement(signal, "{%s}Code" % rsp).text = code
|
|
316
318
|
return self.wsman.signal(self.resource_uri, signal, selector_set=self._selector_set)
|
|
317
319
|
|
|
320
|
+
def _get_shell(
|
|
321
|
+
self,
|
|
322
|
+
*,
|
|
323
|
+
timeout: int | None = None,
|
|
324
|
+
) -> dict[str, str | None]:
|
|
325
|
+
"""Queries the WSMan host for the current shell state.
|
|
326
|
+
|
|
327
|
+
Returns the WSMan GetResponse fields as a dictionary.
|
|
328
|
+
|
|
329
|
+
The timeout parameter can be used to override the default WSMan timeout
|
|
330
|
+
for this operation. If set the HTTP connect and read timeout will be
|
|
331
|
+
set to this value + 2 seconds to ensure the request does not block for
|
|
332
|
+
a longer time.
|
|
333
|
+
|
|
334
|
+
:param timeout: Sets the WSMan operational timeout to this value in
|
|
335
|
+
seconds. Overrides the default timeout set on the WSMan instance.
|
|
336
|
+
:return: A dictionary containing the shell state fields from the raw XML
|
|
337
|
+
response.
|
|
338
|
+
"""
|
|
339
|
+
rsp = NAMESPACES["rsp"]
|
|
340
|
+
|
|
341
|
+
resp = self.wsman.get(
|
|
342
|
+
"http://schemas.microsoft.com/wbem/wsman/1/windows/shell",
|
|
343
|
+
selector_set=self._selector_set,
|
|
344
|
+
timeout=timeout,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
state = {}
|
|
348
|
+
shell_items = resp.find("rsp:Shell", namespaces=NAMESPACES)
|
|
349
|
+
if shell_items is not None:
|
|
350
|
+
for child in shell_items:
|
|
351
|
+
field_name = child.tag.replace(f"{{{rsp}}}", "")
|
|
352
|
+
state[field_name] = child.text
|
|
353
|
+
|
|
354
|
+
return state
|
|
355
|
+
|
|
318
356
|
def _parse_shell_create(
|
|
319
357
|
self,
|
|
320
358
|
response: ET.Element,
|
pypsrp/wsman.py
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
# Copyright: (c) 2018, Jordan Borean (@jborean93) <jborean93@gmail.com>
|
|
2
2
|
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
|
|
3
3
|
|
|
4
|
-
from __future__ import
|
|
4
|
+
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import ipaddress
|
|
7
7
|
import logging
|
|
8
8
|
import re
|
|
9
|
+
import time
|
|
9
10
|
import typing
|
|
10
11
|
import uuid
|
|
11
12
|
import warnings
|
|
12
13
|
import xml.etree.ElementTree as ET
|
|
13
|
-
from xml.dom.minidom import Element
|
|
14
14
|
|
|
15
15
|
import requests
|
|
16
16
|
from requests.packages.urllib3.util.retry import Retry
|
|
17
17
|
|
|
18
|
+
from pypsrp import _pool_manager
|
|
18
19
|
from pypsrp._utils import get_hostname, to_string, to_unicode
|
|
19
20
|
from pypsrp.encryption import WinRMEncryption
|
|
20
21
|
from pypsrp.exceptions import (
|
|
@@ -40,7 +41,7 @@ log = logging.getLogger(__name__)
|
|
|
40
41
|
SUPPORTED_AUTHS = ["basic", "certificate", "credssp", "kerberos", "negotiate", "ntlm"]
|
|
41
42
|
|
|
42
43
|
AUTH_KWARGS: typing.Dict[str, typing.List[str]] = {
|
|
43
|
-
"certificate": ["certificate_key_pem", "certificate_pem"],
|
|
44
|
+
"certificate": ["certificate_key_pem", "certificate_pem", "certificate_key_password"],
|
|
44
45
|
"credssp": ["credssp_auth_mechanism", "credssp_disable_tlsv1_2", "credssp_minimum_version"],
|
|
45
46
|
"negotiate": ["negotiate_delegate", "negotiate_hostname_override", "negotiate_send_cbt", "negotiate_service"],
|
|
46
47
|
}
|
|
@@ -364,7 +365,18 @@ class WSMan(object):
|
|
|
364
365
|
selector_set: typing.Optional["SelectorSet"] = None,
|
|
365
366
|
timeout: typing.Optional[int] = None,
|
|
366
367
|
) -> ET.Element:
|
|
367
|
-
|
|
368
|
+
# Receiving data can sometimes timeout if the server has bounced the
|
|
369
|
+
# network adapter. Luckily just sending the exact same request again
|
|
370
|
+
# will return the same data so we can safely retry
|
|
371
|
+
res = self.invoke(
|
|
372
|
+
WSManAction.RECEIVE,
|
|
373
|
+
resource_uri,
|
|
374
|
+
resource,
|
|
375
|
+
option_set,
|
|
376
|
+
selector_set,
|
|
377
|
+
timeout,
|
|
378
|
+
retries_on_read_timeout=5,
|
|
379
|
+
)
|
|
368
380
|
return res.find("s:Body", namespaces=NAMESPACES) # type: ignore[return-value] # WSMan always has this present
|
|
369
381
|
|
|
370
382
|
def reconnect(
|
|
@@ -437,6 +449,8 @@ class WSMan(object):
|
|
|
437
449
|
option_set: typing.Optional["OptionSet"] = None,
|
|
438
450
|
selector_set: typing.Optional["SelectorSet"] = None,
|
|
439
451
|
timeout: typing.Optional[int] = None,
|
|
452
|
+
*,
|
|
453
|
+
retries_on_read_timeout: int = 0,
|
|
440
454
|
) -> ET.Element:
|
|
441
455
|
"""
|
|
442
456
|
Send a generic WSMan request to the host.
|
|
@@ -451,11 +465,21 @@ class WSMan(object):
|
|
|
451
465
|
:param selector_set: a wsman.SelectorSet to add to the request
|
|
452
466
|
:param timeout: Override the default wsman:OperationTimeout value for
|
|
453
467
|
the request, this should be an int in seconds.
|
|
468
|
+
:param retries_on_read_timeout: The number of retries to attempt if the
|
|
469
|
+
request fails due to a read timeout.
|
|
454
470
|
:return: The ET Element of the response XML from the server
|
|
455
471
|
"""
|
|
456
472
|
s = NAMESPACES["s"]
|
|
457
473
|
envelope = ET.Element("{%s}Envelope" % s)
|
|
458
474
|
|
|
475
|
+
http_timeout = None
|
|
476
|
+
if timeout:
|
|
477
|
+
# Failsafe if the operation timeout exceeds we don't want the
|
|
478
|
+
# request to hang indefinitely. The HTTP timeout needs to be more
|
|
479
|
+
# than the WSMan timeout as we cannot guarantee a WSMan response
|
|
480
|
+
# until the operation timeout is hit.
|
|
481
|
+
http_timeout = timeout + 2
|
|
482
|
+
|
|
459
483
|
message_id, header = self._create_header(action, resource_uri, option_set, selector_set, timeout)
|
|
460
484
|
message_id = f"uuid:{message_id}"
|
|
461
485
|
envelope.append(header)
|
|
@@ -467,7 +491,11 @@ class WSMan(object):
|
|
|
467
491
|
xml = ET.tostring(envelope, encoding="utf-8", method="xml")
|
|
468
492
|
|
|
469
493
|
try:
|
|
470
|
-
response = self.transport.send(
|
|
494
|
+
response = self.transport.send(
|
|
495
|
+
xml,
|
|
496
|
+
retries_on_read_timeout=retries_on_read_timeout,
|
|
497
|
+
timeout=http_timeout,
|
|
498
|
+
)
|
|
471
499
|
except WinRMTransportError as err:
|
|
472
500
|
try:
|
|
473
501
|
# try and parse the XML and get the WSManFault
|
|
@@ -570,9 +598,9 @@ class WSMan(object):
|
|
|
570
598
|
ET.SubElement(header, "{%s}OperationTimeout" % wsman).text = "PT%sS" % str(timeout or self.operation_timeout)
|
|
571
599
|
|
|
572
600
|
reply_to = ET.SubElement(header, "{%s}ReplyTo" % wsa)
|
|
573
|
-
ET.SubElement(
|
|
574
|
-
|
|
575
|
-
)
|
|
601
|
+
ET.SubElement(reply_to, "{%s}Address" % wsa, attrib={"{%s}mustUnderstand" % s: "true"}).text = (
|
|
602
|
+
"http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous"
|
|
603
|
+
)
|
|
576
604
|
|
|
577
605
|
ET.SubElement(header, "{%s}ResourceURI" % wsman, attrib={"{%s}mustUnderstand" % s: "true"}).text = resource_uri
|
|
578
606
|
|
|
@@ -617,7 +645,7 @@ class WSMan(object):
|
|
|
617
645
|
if reason_info is not None:
|
|
618
646
|
reason = reason_info.text
|
|
619
647
|
|
|
620
|
-
wsman_fault = fault.find("s:Detail/wsmanfault:WSManFault", namespaces=NAMESPACES) if fault else None
|
|
648
|
+
wsman_fault = fault.find("s:Detail/wsmanfault:WSManFault", namespaces=NAMESPACES) if fault is not None else None
|
|
621
649
|
if wsman_fault is not None:
|
|
622
650
|
code = wsman_fault.attrib.get("Code", code)
|
|
623
651
|
machine = wsman_fault.attrib.get("Machine")
|
|
@@ -772,6 +800,7 @@ class _TransportHTTP(object):
|
|
|
772
800
|
|
|
773
801
|
self.certificate_key_pem: typing.Optional[str] = None
|
|
774
802
|
self.certificate_pem: typing.Optional[str] = None
|
|
803
|
+
self.certificate_key_password: typing.Optional[str] = None
|
|
775
804
|
for kwarg_list in AUTH_KWARGS.values():
|
|
776
805
|
for kwarg in kwarg_list:
|
|
777
806
|
setattr(self, kwarg, kwargs.get(kwarg, None))
|
|
@@ -790,17 +819,35 @@ class _TransportHTTP(object):
|
|
|
790
819
|
if self.session:
|
|
791
820
|
self.session.close()
|
|
792
821
|
|
|
793
|
-
def send(
|
|
822
|
+
def send(
|
|
823
|
+
self,
|
|
824
|
+
message: bytes,
|
|
825
|
+
*,
|
|
826
|
+
retries_on_read_timeout: int = 0,
|
|
827
|
+
timeout: int | float | tuple[int | float, int | float] | None = None,
|
|
828
|
+
) -> bytes:
|
|
794
829
|
hostname = get_hostname(self.endpoint)
|
|
795
830
|
if self.session is None:
|
|
796
831
|
self.session = self._build_session()
|
|
797
832
|
|
|
833
|
+
attempt = 0
|
|
834
|
+
while True:
|
|
798
835
|
# need to send an initial blank message to setup the security
|
|
799
836
|
# context required for encryption
|
|
800
|
-
if self.wrap_required:
|
|
837
|
+
if self.wrap_required and not self.encryption:
|
|
801
838
|
request = requests.Request("POST", self.endpoint, data=None)
|
|
802
839
|
prep_request = self.session.prepare_request(request)
|
|
803
|
-
|
|
840
|
+
|
|
841
|
+
try:
|
|
842
|
+
self._send_request(prep_request, timeout=timeout)
|
|
843
|
+
except (requests.ReadTimeout, requests.ConnectTimeout) as e:
|
|
844
|
+
log.exception("%s during initial authentication request - attempt %d", type(e).__name__, attempt)
|
|
845
|
+
if attempt == retries_on_read_timeout:
|
|
846
|
+
raise
|
|
847
|
+
|
|
848
|
+
attempt += 1
|
|
849
|
+
time.sleep(self.reconnection_backoff * (2**attempt))
|
|
850
|
+
continue
|
|
804
851
|
|
|
805
852
|
protocol = WinRMEncryption.SPNEGO
|
|
806
853
|
if isinstance(self.session.auth, HttpCredSSPAuth):
|
|
@@ -811,33 +858,55 @@ class _TransportHTTP(object):
|
|
|
811
858
|
|
|
812
859
|
self.encryption = WinRMEncryption(self.session.auth.contexts[hostname], protocol) # type: ignore[union-attr] # This should not happen
|
|
813
860
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
headers = self.session.headers
|
|
821
|
-
if self.wrap_required:
|
|
822
|
-
content_type, payload = self.encryption.wrap_message(message) # type: ignore[union-attr] # This should not happen
|
|
823
|
-
protocol = self.encryption.protocol if self.encryption else WinRMEncryption.SPNEGO
|
|
824
|
-
type_header = '%s;protocol="%s";boundary="Encrypted Boundary"' % (content_type, protocol)
|
|
825
|
-
headers.update(
|
|
826
|
-
{
|
|
827
|
-
"Content-Type": type_header,
|
|
828
|
-
"Content-Length": str(len(payload)),
|
|
829
|
-
}
|
|
830
|
-
)
|
|
831
|
-
else:
|
|
832
|
-
payload = message
|
|
833
|
-
headers["Content-Type"] = "application/soap+xml;charset=UTF-8"
|
|
861
|
+
if log.isEnabledFor(logging.DEBUG):
|
|
862
|
+
log.debug("Sending message on attempt %d: %s" % (attempt, message.decode("utf-8")))
|
|
863
|
+
# for testing, keep commented out
|
|
864
|
+
# self._test_messages.append({"request": message.decode('utf-8'),
|
|
865
|
+
# "response": None})
|
|
834
866
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
867
|
+
headers = self.session.headers.copy() # type: ignore[attr-defined] # We cannot use copy.copy as it still is a ref to the original.
|
|
868
|
+
if self.wrap_required:
|
|
869
|
+
content_type, payload = self.encryption.wrap_message(message) # type: ignore[union-attr] # This should not happen
|
|
870
|
+
protocol = self.encryption.protocol if self.encryption else WinRMEncryption.SPNEGO
|
|
871
|
+
type_header = '%s;protocol="%s";boundary="Encrypted Boundary"' % (content_type, protocol)
|
|
872
|
+
headers.update(
|
|
873
|
+
{
|
|
874
|
+
"Content-Type": type_header,
|
|
875
|
+
"Content-Length": str(len(payload)),
|
|
876
|
+
}
|
|
877
|
+
)
|
|
878
|
+
else:
|
|
879
|
+
payload = message
|
|
880
|
+
headers["Content-Type"] = "application/soap+xml;charset=UTF-8"
|
|
881
|
+
|
|
882
|
+
request = requests.Request("POST", self.endpoint, data=payload, headers=headers)
|
|
883
|
+
prep_request = self.session.prepare_request(request)
|
|
884
|
+
try:
|
|
885
|
+
return self._send_request(
|
|
886
|
+
prep_request,
|
|
887
|
+
timeout=timeout,
|
|
888
|
+
)
|
|
889
|
+
except (requests.ReadTimeout, requests.ConnectTimeout) as e:
|
|
890
|
+
log.exception("%s during WSMan request - attempt %d", type(e).__name__, attempt)
|
|
891
|
+
if attempt == retries_on_read_timeout:
|
|
892
|
+
raise
|
|
838
893
|
|
|
839
|
-
|
|
840
|
-
|
|
894
|
+
# On a failure the encryption state is invalidated and needs to
|
|
895
|
+
# be recreated after authentication is done.
|
|
896
|
+
self.encryption = None
|
|
897
|
+
|
|
898
|
+
attempt += 1
|
|
899
|
+
time.sleep(self.reconnection_backoff * (2**attempt - 1))
|
|
900
|
+
|
|
901
|
+
def _send_request(
|
|
902
|
+
self,
|
|
903
|
+
request: requests.PreparedRequest,
|
|
904
|
+
timeout: int | float | tuple[int | float, int | float] | None = None,
|
|
905
|
+
) -> bytes:
|
|
906
|
+
response = self.session.send( # type: ignore[union-attr] # This should not happen
|
|
907
|
+
request,
|
|
908
|
+
timeout=timeout if timeout is not None else (self.connection_timeout, self.read_timeout),
|
|
909
|
+
)
|
|
841
910
|
|
|
842
911
|
content_type = response.headers.get("content-type", "")
|
|
843
912
|
if content_type.startswith("multipart/encrypted;") or content_type.startswith("multipart/x-multi-encrypted;"):
|
|
@@ -878,7 +947,7 @@ class _TransportHTTP(object):
|
|
|
878
947
|
|
|
879
948
|
# get the env requests settings
|
|
880
949
|
session.trust_env = True
|
|
881
|
-
settings = session.merge_environment_settings(
|
|
950
|
+
settings = session.merge_environment_settings(
|
|
882
951
|
url=self.endpoint, proxies={}, stream=None, verify=None, cert=None
|
|
883
952
|
)
|
|
884
953
|
|
|
@@ -915,8 +984,14 @@ class _TransportHTTP(object):
|
|
|
915
984
|
del retry_kwargs["status"]
|
|
916
985
|
retries = Retry(**retry_kwargs)
|
|
917
986
|
|
|
918
|
-
session.mount(
|
|
919
|
-
|
|
987
|
+
session.mount(
|
|
988
|
+
"http://",
|
|
989
|
+
_pool_manager.create_request_adapter(max_retries=retries),
|
|
990
|
+
)
|
|
991
|
+
session.mount(
|
|
992
|
+
"https://",
|
|
993
|
+
_pool_manager.create_request_adapter(max_retries=retries, key_password=self.certificate_key_password),
|
|
994
|
+
)
|
|
920
995
|
|
|
921
996
|
# set cert validation config
|
|
922
997
|
session.verify = self.cert_validation
|
|
@@ -954,6 +1029,9 @@ class _TransportHTTP(object):
|
|
|
954
1029
|
if self.ssl is False:
|
|
955
1030
|
raise ValueError("For certificate auth, SSL must be used")
|
|
956
1031
|
|
|
1032
|
+
# requests does not expose the password through the cert tuple. If set
|
|
1033
|
+
# it'll be passed to urllib3 through the custom adapter created for the
|
|
1034
|
+
# session.
|
|
957
1035
|
session.cert = (self.certificate_pem, self.certificate_key_pem)
|
|
958
1036
|
session.headers["Authorization"] = "http://schemas.dmtf.org/wbem/wsman/1/wsman/secprofile/https/mutual"
|
|
959
1037
|
|
|
@@ -1,30 +1,44 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pypsrp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0rc2
|
|
4
4
|
Summary: PowerShell Remoting Protocol and WinRM for Python
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
License: MIT
|
|
5
|
+
Author-email: Jordan Borean <jborean93@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: homepage, https://github.com/jborean93/pypsrp
|
|
9
8
|
Keywords: winrm,psrp,winrs,windows,powershell
|
|
10
|
-
Platform: UNKNOWN
|
|
11
9
|
Classifier: Development Status :: 4 - Beta
|
|
12
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
13
10
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.6
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.7
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
18
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.10
|
|
19
17
|
Description-Content-Type: text/markdown
|
|
20
18
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: cryptography
|
|
22
|
-
Requires-Dist: pyspnego
|
|
23
|
-
Requires-Dist: requests
|
|
19
|
+
Requires-Dist: cryptography>=3.1
|
|
20
|
+
Requires-Dist: pyspnego<1.0.0,>=0.7.0
|
|
21
|
+
Requires-Dist: requests>=2.27.0
|
|
24
22
|
Provides-Extra: credssp
|
|
25
|
-
Requires-Dist: requests-credssp
|
|
23
|
+
Requires-Dist: requests-credssp>=2.0.0; extra == "credssp"
|
|
26
24
|
Provides-Extra: kerberos
|
|
27
|
-
Requires-Dist: pyspnego[kerberos]
|
|
25
|
+
Requires-Dist: pyspnego[kerberos]; extra == "kerberos"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: black==25.11.0; extra == "dev"
|
|
28
|
+
Requires-Dist: build; extra == "dev"
|
|
29
|
+
Requires-Dist: isort==6.1.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy==1.19.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
32
|
+
Requires-Dist: pyspnego[kerberos]; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
36
|
+
Requires-Dist: PyYAML; extra == "dev"
|
|
37
|
+
Requires-Dist: requests-credssp; extra == "dev"
|
|
38
|
+
Requires-Dist: types-requests; extra == "dev"
|
|
39
|
+
Requires-Dist: types-PyYAML; extra == "dev"
|
|
40
|
+
Requires-Dist: xmldiff; extra == "dev"
|
|
41
|
+
Dynamic: license-file
|
|
28
42
|
|
|
29
43
|
# pypsrp - Python PowerShell Remoting Protocol Client library
|
|
30
44
|
|
|
@@ -69,7 +83,7 @@ libraries to be installed.
|
|
|
69
83
|
|
|
70
84
|
See `How to Install` for more details
|
|
71
85
|
|
|
72
|
-
* CPython 3.
|
|
86
|
+
* CPython 3.10+
|
|
73
87
|
* [cryptography](https://github.com/pyca/cryptography)
|
|
74
88
|
* [pyspnego](https://github.com/jborean93/pyspnego)
|
|
75
89
|
* [requests](https://github.com/requests/requests)
|
|
@@ -155,7 +169,19 @@ pip install pypsrp[credssp]
|
|
|
155
169
|
```
|
|
156
170
|
|
|
157
171
|
If that fails you may need to update pip and setuptools to a newer version
|
|
158
|
-
`pip install -U pip setuptools
|
|
172
|
+
`pip install -U pip setuptools`, otherwise the following system package may be
|
|
173
|
+
required;
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
# For Debian/Ubuntu
|
|
177
|
+
apt-get install gcc python-dev
|
|
178
|
+
|
|
179
|
+
# For RHEL/Centos
|
|
180
|
+
yum install gcc python-devel
|
|
181
|
+
|
|
182
|
+
# For Fedora
|
|
183
|
+
dnf install gcc python-devel
|
|
184
|
+
```
|
|
159
185
|
|
|
160
186
|
|
|
161
187
|
## How to Use
|
|
@@ -198,7 +224,8 @@ These are the options that can be used to setup `WSMan`;
|
|
|
198
224
|
* `data_locale`: The `wsmv:DataLocale` value to set on each WSMan request. This specifies the format in which numerical data is presented in the response text, default is the value of `locale`
|
|
199
225
|
* `reconnection_retries`: Number of retries on a connection problem, default is `0`
|
|
200
226
|
* `reconnection_backoff`: Number of seconds to backoff in between reconnection attempts (first sleeps X, then sleeps 2*X, 4*X, 8*X, ...), default is `2.0`
|
|
201
|
-
* `certificate_key_pem`: The path to the certificate key used in `certificate` authentication
|
|
227
|
+
* `certificate_key_pem`: The path to the certificate key used in `certificate` authentication. The key can be in either a `PKCS#1` or `PKCS#8` format
|
|
228
|
+
* `certificate_key_password`: The password for `certificate_key_pem` if it is encrypted
|
|
202
229
|
* `certificate_pem`: The path to the certificate used in `certificate` authentication
|
|
203
230
|
* `credssp_auth_mechanism`: The sub-auth mechanism used in CredSSP, default is `auto`, choices are `auto`, `ntlm`, or `kerberos`
|
|
204
231
|
* `credssp_disable_tlsv1_2`: Whether to used CredSSP auth over the insecure TLSv1.0, default is `False`
|
|
@@ -235,7 +262,7 @@ configure a `WinRS` shell;
|
|
|
235
262
|
* `idle_time_out`: THe idle timeout in seconds of the shell
|
|
236
263
|
* `lifetime`: The total lifetime of the shell
|
|
237
264
|
* `name`: The name (description only) of the shell
|
|
238
|
-
* `no_profile`: Whether to create the shell with the user profile loaded or not
|
|
265
|
+
* `no_profile`: Whether to create the shell with the user profile loaded or not. This no longer works on Server 2012/Windows 8 or newer
|
|
239
266
|
* `working_directory`: The default working directory of the created shell
|
|
240
267
|
|
|
241
268
|
`RunspacePool` is a shell used by the PSRP protocol, it is designed to be a
|
|
@@ -252,6 +279,7 @@ Here are the options that can be used to configure a `RunspacePool` shell;
|
|
|
252
279
|
* `min_runspaces`: The minimuum number of runspaces that a pool can hold, default is 1
|
|
253
280
|
* `max_runspaces`: The maximum number of runspaces that a pool can hold. Each PowerShell pipeline is run in a single Runspace, default is 1
|
|
254
281
|
* `session_key_timeout_ms`: The maximum time to wait for a session key transfer from the server
|
|
282
|
+
* `no_profile`: Do not load the user profile on the remote Runspace Pool
|
|
255
283
|
|
|
256
284
|
### Process
|
|
257
285
|
|
|
@@ -357,9 +385,8 @@ from pypsrp.wsman import WSMan
|
|
|
357
385
|
# creates a https connection with explicit kerberos auth and implicit credentials
|
|
358
386
|
wsman = WSMan("server", auth="kerberos", cert_validation=False))
|
|
359
387
|
|
|
360
|
-
with wsman, RunspacePool(wsman) as pool:
|
|
388
|
+
with wsman, RunspacePool(wsman) as pool, PowerShell(pool) as ps:
|
|
361
389
|
# execute 'Get-Process | Select-Object Name'
|
|
362
|
-
ps = PowerShell(pool)
|
|
363
390
|
ps.add_cmdlet("Get-Process").add_cmdlet("Select-Object").add_argument("Name")
|
|
364
391
|
output = ps.invoke()
|
|
365
392
|
|
|
@@ -390,6 +417,19 @@ with wsman, RunspacePool(wsman) as pool:
|
|
|
390
417
|
ps.invoke(["string", 1])
|
|
391
418
|
print(ps.output)
|
|
392
419
|
print(ps.streams.debug)
|
|
420
|
+
|
|
421
|
+
# It is possible to run the PowerShell pipeline again with invoke() but it
|
|
422
|
+
# needs to be explicitly closed first and the commands/streams optionally
|
|
423
|
+
# cleared if desired.
|
|
424
|
+
ps.close()
|
|
425
|
+
|
|
426
|
+
# Clears out ps.output and ps.streams to a blank value. Not required but
|
|
427
|
+
# nice if the output should be separate from a previous run
|
|
428
|
+
ps.clear_streams()
|
|
429
|
+
|
|
430
|
+
# Removes all existing commands. Not required but needed if re-using the
|
|
431
|
+
# same pipeline with a different set of commands
|
|
432
|
+
ps.clear_commands()
|
|
393
433
|
```
|
|
394
434
|
|
|
395
435
|
|
|
@@ -443,17 +483,18 @@ information and should only be used for debugging purposes._
|
|
|
443
483
|
## Testing
|
|
444
484
|
|
|
445
485
|
Any changes are more than welcome in pull request form, you can run the current
|
|
446
|
-
test suite with
|
|
486
|
+
test suite with:
|
|
447
487
|
|
|
448
488
|
```bash
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
489
|
+
pip install -e .[dev]
|
|
490
|
+
|
|
491
|
+
python -m pytest \
|
|
492
|
+
tests/tests_pypsrp \
|
|
493
|
+
--verbose \
|
|
494
|
+
--junitxml junit/test-results.xml \
|
|
495
|
+
--cov pypsrp \
|
|
496
|
+
--cov-report xml \
|
|
497
|
+
--cov-report term-missing
|
|
457
498
|
```
|
|
458
499
|
|
|
459
500
|
A lot of the tests either simulate a remote Windows host but you can also run a
|
|
@@ -523,5 +564,3 @@ tests.
|
|
|
523
564
|
* Add Ansible playbook for better integration tests
|
|
524
565
|
* Improved serialization between Python and .NET objects
|
|
525
566
|
* Live interactive console for PSRP
|
|
526
|
-
|
|
527
|
-
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
pypsrp/__init__.py,sha256=9aButMuBNIEph0LfHsodNd8uSS7XKZj9IPHJsPd8hjY,958
|
|
2
|
+
pypsrp/_pool_manager.py,sha256=yKUzdzkE4hEo2mqIC-kKPL9uWpIk9mixCQ1AP5rgn_o,4222
|
|
3
|
+
pypsrp/_utils.py,sha256=y9sezXQPl0vXnr1beyZ8xcn-Q_zdg_7cg_lPk9QmjVQ,3540
|
|
4
|
+
pypsrp/client.py,sha256=iJERbEXwwE4wswpqAL0Dcz8ACqLvXUT9Dcl_Qqa3830,13892
|
|
5
|
+
pypsrp/complex_objects.py,sha256=PkJ1F1Byw5RI_Vpfy1KfcMFJdOfioOSsIbi50S_fZ3g,62666
|
|
6
|
+
pypsrp/encryption.py,sha256=4wOontH-eF8BBxTyHTX9iAF0uIM6m-z3N9poatspP1A,3870
|
|
7
|
+
pypsrp/exceptions.py,sha256=SWdQ9UAPOIwr7B4twGqDoXLCEtXFK_5u3PYIVLvEIbA,4034
|
|
8
|
+
pypsrp/host.py,sha256=XQgnkwzsZprNmAr1TPJpEBmj3xRSWFrYQFSIi870aNE,45065
|
|
9
|
+
pypsrp/messages.py,sha256=mCuie1ywOQmkXltISPvrb6ITS6GUA0oSld2jfNLdBf4,34169
|
|
10
|
+
pypsrp/negotiate.py,sha256=iVX2J-slABamesMajjTfmfqucDS4n5vVTTde_IDqpuE,10951
|
|
11
|
+
pypsrp/powershell.py,sha256=NRDoFtOsYjIrJHMQ6r6vRq6uL2EtPhhem5ZA_bWoDnw,69296
|
|
12
|
+
pypsrp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
pypsrp/serializer.py,sha256=49rBxfo3fh511mU3uzoK9vFvIOrBtyX3J-CUugbezc0,33767
|
|
14
|
+
pypsrp/shell.py,sha256=80mh35c-UXjsy_MFG9J3GGYLYqvk2FppUgrFHkWh6xg,18065
|
|
15
|
+
pypsrp/wsman.py,sha256=KkRg9UDDq1yxY4TKK9mGI-vS6px6ftQDPhmeojjItKg,48728
|
|
16
|
+
pypsrp/pwsh_scripts/__init__.py,sha256=VckRBWWDUdh_LTwPspE2FXSo8TaT50QnZ1aZ-yvxJmU,139
|
|
17
|
+
pypsrp/pwsh_scripts/copy.ps1,sha256=sTy4jcnh0cV5Y0l2-dd1znc-eM_kgE-okk51xZzdIQk,5374
|
|
18
|
+
pypsrp/pwsh_scripts/fetch.ps1,sha256=7gvPxURnksZetYgjWRjbIDIWSJYv2xGXZjRymvNUj6w,1989
|
|
19
|
+
pypsrp-0.9.0rc2.dist-info/licenses/LICENSE,sha256=8uJLgs8Wb_Us_8XaTvOOn7Yf0dwzFi9pcFHVxfgJKoE,1079
|
|
20
|
+
pypsrp-0.9.0rc2.dist-info/METADATA,sha256=_zCveXjKqq1_OiCmjLtge2Zr3v1G-S2EFrF33PTd6QA,23630
|
|
21
|
+
pypsrp-0.9.0rc2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
22
|
+
pypsrp-0.9.0rc2.dist-info/top_level.txt,sha256=9sFQD0PMIG07xbaR4j-1Fq7NaD9kwInn_HTc3Gqznq0,7
|
|
23
|
+
pypsrp-0.9.0rc2.dist-info/RECORD,,
|
pypsrp-0.8.1.dist-info/RECORD
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
pypsrp/__init__.py,sha256=9aButMuBNIEph0LfHsodNd8uSS7XKZj9IPHJsPd8hjY,958
|
|
2
|
-
pypsrp/_utils.py,sha256=BKXpmraKuyWt5N89ViuC91wsF7AB1XPyViE_1_Cl4w0,3541
|
|
3
|
-
pypsrp/client.py,sha256=sp9rgNwAGJXXaMgdYdJ7yMd_q1FjYg1nlpVwT_yncSw,13941
|
|
4
|
-
pypsrp/complex_objects.py,sha256=D2y7IaxN3_n72Er6vJxFFlui9yHORVopGxIVManlCDg,62744
|
|
5
|
-
pypsrp/encryption.py,sha256=elgz6Fph6ZYsvN7ZT1FFSH7Q05UYS8Nh5kiJvSyWy-o,3871
|
|
6
|
-
pypsrp/exceptions.py,sha256=SWdQ9UAPOIwr7B4twGqDoXLCEtXFK_5u3PYIVLvEIbA,4034
|
|
7
|
-
pypsrp/host.py,sha256=XQgnkwzsZprNmAr1TPJpEBmj3xRSWFrYQFSIi870aNE,45065
|
|
8
|
-
pypsrp/messages.py,sha256=GBKv2Ilm0oDV8lHVbYDkx41AckoOGIhQG-HovbLnvbA,34093
|
|
9
|
-
pypsrp/negotiate.py,sha256=ZS1NL2U17CFiDE3B_U2AprRz_BfBO7iFC5jkSO_bi1E,11073
|
|
10
|
-
pypsrp/powershell.py,sha256=10TpFRSvMi8VVtkkSklQCBftcagzLpN2ZginSbnyDVA,65595
|
|
11
|
-
pypsrp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
-
pypsrp/serializer.py,sha256=KhT1l0ay2wQfncAXY-KY9ZQfVMKhjCWgjL7O1bywEhA,33759
|
|
13
|
-
pypsrp/shell.py,sha256=DA-yj80PdtfxpOAmJrs6k5BmjqjE5-ZeDU2C9rgli6k,16697
|
|
14
|
-
pypsrp/wsman.py,sha256=v2ZkKGfKY502VhA8XFdXKyEPbEoc3bTWJ486SRbW2do,45641
|
|
15
|
-
pypsrp/pwsh_scripts/__init__.py,sha256=VckRBWWDUdh_LTwPspE2FXSo8TaT50QnZ1aZ-yvxJmU,139
|
|
16
|
-
pypsrp/pwsh_scripts/copy.ps1,sha256=sTy4jcnh0cV5Y0l2-dd1znc-eM_kgE-okk51xZzdIQk,5374
|
|
17
|
-
pypsrp/pwsh_scripts/fetch.ps1,sha256=7gvPxURnksZetYgjWRjbIDIWSJYv2xGXZjRymvNUj6w,1989
|
|
18
|
-
pypsrp-0.8.1.dist-info/LICENSE,sha256=8uJLgs8Wb_Us_8XaTvOOn7Yf0dwzFi9pcFHVxfgJKoE,1079
|
|
19
|
-
pypsrp-0.8.1.dist-info/METADATA,sha256=lB6x7GQTrp8L1flRqu7Np2XScvrS43pjj7mmR8LyEQU,22012
|
|
20
|
-
pypsrp-0.8.1.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
|
|
21
|
-
pypsrp-0.8.1.dist-info/top_level.txt,sha256=9sFQD0PMIG07xbaR4j-1Fq7NaD9kwInn_HTc3Gqznq0,7
|
|
22
|
-
pypsrp-0.8.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
23
|
-
pypsrp-0.8.1.dist-info/RECORD,,
|
pypsrp-0.8.1.dist-info/zip-safe
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
File without changes
|
|
File without changes
|