pypsrp 0.8.1__py3-none-any.whl → 0.9.0rc1__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/_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 +3 -6
- pypsrp/powershell.py +59 -8
- pypsrp/serializer.py +21 -18
- pypsrp/shell.py +3 -3
- pypsrp/wsman.py +78 -34
- {pypsrp-0.8.1.dist-info → pypsrp-0.9.0rc1.dist-info}/METADATA +71 -33
- pypsrp-0.9.0rc1.dist-info/RECORD +22 -0
- {pypsrp-0.8.1.dist-info → pypsrp-0.9.0rc1.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.0rc1.dist-info/licenses}/LICENSE +0 -0
- {pypsrp-0.8.1.dist-info → pypsrp-0.9.0rc1.dist-info}/top_level.txt +0 -0
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
|
|
@@ -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
|
@@ -11,7 +11,6 @@ import uuid
|
|
|
11
11
|
import warnings
|
|
12
12
|
import xml.etree.ElementTree as ET
|
|
13
13
|
|
|
14
|
-
from cryptography.hazmat.backends import default_backend
|
|
15
14
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
16
15
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
17
16
|
|
|
@@ -103,6 +102,8 @@ class RunspacePool(object):
|
|
|
103
102
|
min_runspaces: int = 1,
|
|
104
103
|
max_runspaces: int = 1,
|
|
105
104
|
session_key_timeout_ms: int = 60000,
|
|
105
|
+
*,
|
|
106
|
+
no_profile: bool = False,
|
|
106
107
|
) -> None:
|
|
107
108
|
"""
|
|
108
109
|
Represents a Runspace pool on a remote host. This pool can contain
|
|
@@ -129,6 +130,8 @@ class RunspacePool(object):
|
|
|
129
130
|
hold
|
|
130
131
|
:param session_key_timeout_ms: The maximum time to wait for a session
|
|
131
132
|
key transfer from the server
|
|
133
|
+
:param no_profile: If True, the user profile will not be loaded and
|
|
134
|
+
will use the machine defaults.
|
|
132
135
|
"""
|
|
133
136
|
log.info("Initialising RunspacePool object for configuration %s" % configuration_name)
|
|
134
137
|
# The below are defined in some way at
|
|
@@ -138,7 +141,12 @@ class RunspacePool(object):
|
|
|
138
141
|
self.connection = connection
|
|
139
142
|
resource_uri = "http://schemas.microsoft.com/powershell/%s" % configuration_name
|
|
140
143
|
self.shell = WinRS(
|
|
141
|
-
connection,
|
|
144
|
+
connection,
|
|
145
|
+
resource_uri=resource_uri,
|
|
146
|
+
id=self.id,
|
|
147
|
+
input_streams="stdin pr",
|
|
148
|
+
output_streams="stdout",
|
|
149
|
+
no_profile=no_profile,
|
|
142
150
|
)
|
|
143
151
|
self.ci_table: typing.Dict = {}
|
|
144
152
|
self.pipelines: typing.Dict[str, "PowerShell"] = {}
|
|
@@ -569,7 +577,6 @@ class RunspacePool(object):
|
|
|
569
577
|
self._exchange_key = rsa.generate_private_key(
|
|
570
578
|
public_exponent=65537,
|
|
571
579
|
key_size=2048,
|
|
572
|
-
backend=default_backend(),
|
|
573
580
|
)
|
|
574
581
|
public_numbers = self._exchange_key.public_key().public_numbers()
|
|
575
582
|
exponent = struct.pack("<I", public_numbers.e)
|
|
@@ -861,7 +868,7 @@ class RunspacePool(object):
|
|
|
861
868
|
iv = b"\x00" * 16 # PSRP doesn't use an IV
|
|
862
869
|
algorithm = algorithms.AES(decrypted_key)
|
|
863
870
|
mode = modes.CBC(iv)
|
|
864
|
-
cipher = Cipher(algorithm, mode
|
|
871
|
+
cipher = Cipher(algorithm, mode)
|
|
865
872
|
|
|
866
873
|
self._serializer.cipher = cipher
|
|
867
874
|
self._key_exchanged = True
|
|
@@ -907,7 +914,16 @@ class PowerShell(object):
|
|
|
907
914
|
# CommandID that is created and we need to reference in the WSMan msgs
|
|
908
915
|
self._command_id: typing.Optional[str] = None
|
|
909
916
|
|
|
910
|
-
|
|
917
|
+
def __enter__(self) -> "PowerShell":
|
|
918
|
+
return self
|
|
919
|
+
|
|
920
|
+
def __exit__(
|
|
921
|
+
self,
|
|
922
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
923
|
+
value: typing.Optional[BaseException],
|
|
924
|
+
traceback: typing.Optional[types.TracebackType],
|
|
925
|
+
) -> None:
|
|
926
|
+
self.close()
|
|
911
927
|
|
|
912
928
|
def add_argument(
|
|
913
929
|
self,
|
|
@@ -1042,6 +1058,33 @@ class PowerShell(object):
|
|
|
1042
1058
|
self.commands = []
|
|
1043
1059
|
return self
|
|
1044
1060
|
|
|
1061
|
+
def clear_streams(self) -> None:
|
|
1062
|
+
"""
|
|
1063
|
+
Clears all the data streams of the current PowerShell object.
|
|
1064
|
+
"""
|
|
1065
|
+
self.streams = PSDataStreams()
|
|
1066
|
+
self.output = []
|
|
1067
|
+
|
|
1068
|
+
def close(self) -> None:
|
|
1069
|
+
"""
|
|
1070
|
+
Closes the PowerShell pipeline and cleans up server resources.
|
|
1071
|
+
|
|
1072
|
+
This method must be called to reuse a PowerShell object for multiple
|
|
1073
|
+
invocations. It sends a TERMINATE signal to the server and removes
|
|
1074
|
+
the pipeline from the runspace pool's pipeline registry.
|
|
1075
|
+
|
|
1076
|
+
:raises InvalidPipelineStateError: If the pipeline is not in a terminal
|
|
1077
|
+
state (COMPLETED, STOPPED, or FAILED)
|
|
1078
|
+
"""
|
|
1079
|
+
valid_states = [PSInvocationState.COMPLETED, PSInvocationState.STOPPED, PSInvocationState.FAILED]
|
|
1080
|
+
if self.state == PSInvocationState.NOT_STARTED:
|
|
1081
|
+
return
|
|
1082
|
+
elif self.state not in valid_states:
|
|
1083
|
+
raise InvalidPipelineStateError(self.state, valid_states, "close a PowerShell pipeline")
|
|
1084
|
+
|
|
1085
|
+
self.runspace_pool.shell.signal(SignalCode.TERMINATE, command_id=self._command_id or self.id)
|
|
1086
|
+
self.runspace_pool.pipelines.pop(self.id, None)
|
|
1087
|
+
|
|
1045
1088
|
def connect(self):
|
|
1046
1089
|
"""
|
|
1047
1090
|
Connects to a running command on a remote server, waits until the
|
|
@@ -1124,8 +1167,14 @@ class PowerShell(object):
|
|
|
1124
1167
|
values. Will default to returning the invocation info on all
|
|
1125
1168
|
"""
|
|
1126
1169
|
log.info("Beginning remote Pipeline invocation")
|
|
1127
|
-
|
|
1128
|
-
|
|
1170
|
+
valid_states = [
|
|
1171
|
+
PSInvocationState.NOT_STARTED,
|
|
1172
|
+
PSInvocationState.STOPPED,
|
|
1173
|
+
PSInvocationState.COMPLETED,
|
|
1174
|
+
PSInvocationState.FAILED,
|
|
1175
|
+
]
|
|
1176
|
+
if self.state not in valid_states:
|
|
1177
|
+
raise InvalidPipelineStateError(self.state, valid_states, "start a PowerShell pipeline")
|
|
1129
1178
|
|
|
1130
1179
|
if len(self.commands) == 0:
|
|
1131
1180
|
raise InvalidPSRPOperation("Cannot invoke PowerShell without any commands being set")
|
|
@@ -1414,7 +1463,7 @@ class PowerShell(object):
|
|
|
1414
1463
|
self.state = PSInvocationState.STOPPING
|
|
1415
1464
|
self.runspace_pool.shell.signal(SignalCode.PS_CTRL_C, str(self.id).upper())
|
|
1416
1465
|
self.state = PSInvocationState.STOPPED
|
|
1417
|
-
|
|
1466
|
+
self.runspace_pool.pipelines.pop(self.id, None)
|
|
1418
1467
|
|
|
1419
1468
|
def _invoke(
|
|
1420
1469
|
self,
|
|
@@ -1422,6 +1471,8 @@ class PowerShell(object):
|
|
|
1422
1471
|
) -> None:
|
|
1423
1472
|
fragments = self.runspace_pool._fragmenter.fragment(msg, self.runspace_pool.id, self.id)
|
|
1424
1473
|
|
|
1474
|
+
self.runspace_pool.pipelines[self.id] = self
|
|
1475
|
+
|
|
1425
1476
|
# send first fragment as Command message
|
|
1426
1477
|
first_frag = base64.b64encode(fragments.pop(0)).decode("utf-8")
|
|
1427
1478
|
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
|
@@ -67,7 +67,7 @@ class WinRS(object):
|
|
|
67
67
|
:param lifetime: The total lifetime of the shell
|
|
68
68
|
:param name: The name (description) of the shell
|
|
69
69
|
:param no_profile: Whether to create the shell with the user profile
|
|
70
|
-
active or not
|
|
70
|
+
active or not. This may not work on newer hosts like Server 2012+.
|
|
71
71
|
:param working_directory: The default working directory of the created
|
|
72
72
|
shell
|
|
73
73
|
"""
|
|
@@ -197,8 +197,8 @@ class WinRS(object):
|
|
|
197
197
|
# inherit the base options if it was passed in, otherwise use an empty
|
|
198
198
|
# option set
|
|
199
199
|
options = OptionSet() if base_options is None else base_options
|
|
200
|
-
if self.no_profile
|
|
201
|
-
options.add_option("WINRS_NOPROFILE",
|
|
200
|
+
if self.no_profile:
|
|
201
|
+
options.add_option("WINRS_NOPROFILE", "TRUE", {"MustComply": "true"})
|
|
202
202
|
if self.codepage is not None:
|
|
203
203
|
options.add_option("WINRS_CODEPAGE", str(self.codepage))
|
|
204
204
|
|
pypsrp/wsman.py
CHANGED
|
@@ -6,11 +6,11 @@ from __future__ import division
|
|
|
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
|
|
@@ -364,7 +364,18 @@ class WSMan(object):
|
|
|
364
364
|
selector_set: typing.Optional["SelectorSet"] = None,
|
|
365
365
|
timeout: typing.Optional[int] = None,
|
|
366
366
|
) -> ET.Element:
|
|
367
|
-
|
|
367
|
+
# Receiving data can sometimes timeout if the server has bounced the
|
|
368
|
+
# network adapter. Luckily just sending the exact same request again
|
|
369
|
+
# will return the same data so we can safely retry
|
|
370
|
+
res = self.invoke(
|
|
371
|
+
WSManAction.RECEIVE,
|
|
372
|
+
resource_uri,
|
|
373
|
+
resource,
|
|
374
|
+
option_set,
|
|
375
|
+
selector_set,
|
|
376
|
+
timeout,
|
|
377
|
+
retries_on_read_timeout=5,
|
|
378
|
+
)
|
|
368
379
|
return res.find("s:Body", namespaces=NAMESPACES) # type: ignore[return-value] # WSMan always has this present
|
|
369
380
|
|
|
370
381
|
def reconnect(
|
|
@@ -437,6 +448,8 @@ class WSMan(object):
|
|
|
437
448
|
option_set: typing.Optional["OptionSet"] = None,
|
|
438
449
|
selector_set: typing.Optional["SelectorSet"] = None,
|
|
439
450
|
timeout: typing.Optional[int] = None,
|
|
451
|
+
*,
|
|
452
|
+
retries_on_read_timeout: int = 0,
|
|
440
453
|
) -> ET.Element:
|
|
441
454
|
"""
|
|
442
455
|
Send a generic WSMan request to the host.
|
|
@@ -451,6 +464,8 @@ class WSMan(object):
|
|
|
451
464
|
:param selector_set: a wsman.SelectorSet to add to the request
|
|
452
465
|
:param timeout: Override the default wsman:OperationTimeout value for
|
|
453
466
|
the request, this should be an int in seconds.
|
|
467
|
+
:param retries_on_read_timeout: The number of retries to attempt if the
|
|
468
|
+
request fails due to a read timeout.
|
|
454
469
|
:return: The ET Element of the response XML from the server
|
|
455
470
|
"""
|
|
456
471
|
s = NAMESPACES["s"]
|
|
@@ -467,7 +482,7 @@ class WSMan(object):
|
|
|
467
482
|
xml = ET.tostring(envelope, encoding="utf-8", method="xml")
|
|
468
483
|
|
|
469
484
|
try:
|
|
470
|
-
response = self.transport.send(xml)
|
|
485
|
+
response = self.transport.send(xml, retries_on_read_timeout=retries_on_read_timeout)
|
|
471
486
|
except WinRMTransportError as err:
|
|
472
487
|
try:
|
|
473
488
|
# try and parse the XML and get the WSManFault
|
|
@@ -570,9 +585,9 @@ class WSMan(object):
|
|
|
570
585
|
ET.SubElement(header, "{%s}OperationTimeout" % wsman).text = "PT%sS" % str(timeout or self.operation_timeout)
|
|
571
586
|
|
|
572
587
|
reply_to = ET.SubElement(header, "{%s}ReplyTo" % wsa)
|
|
573
|
-
ET.SubElement(
|
|
574
|
-
|
|
575
|
-
)
|
|
588
|
+
ET.SubElement(reply_to, "{%s}Address" % wsa, attrib={"{%s}mustUnderstand" % s: "true"}).text = (
|
|
589
|
+
"http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous"
|
|
590
|
+
)
|
|
576
591
|
|
|
577
592
|
ET.SubElement(header, "{%s}ResourceURI" % wsman, attrib={"{%s}mustUnderstand" % s: "true"}).text = resource_uri
|
|
578
593
|
|
|
@@ -617,7 +632,7 @@ class WSMan(object):
|
|
|
617
632
|
if reason_info is not None:
|
|
618
633
|
reason = reason_info.text
|
|
619
634
|
|
|
620
|
-
wsman_fault = fault.find("s:Detail/wsmanfault:WSManFault", namespaces=NAMESPACES) if fault else None
|
|
635
|
+
wsman_fault = fault.find("s:Detail/wsmanfault:WSManFault", namespaces=NAMESPACES) if fault is not None else None
|
|
621
636
|
if wsman_fault is not None:
|
|
622
637
|
code = wsman_fault.attrib.get("Code", code)
|
|
623
638
|
machine = wsman_fault.attrib.get("Machine")
|
|
@@ -790,17 +805,34 @@ class _TransportHTTP(object):
|
|
|
790
805
|
if self.session:
|
|
791
806
|
self.session.close()
|
|
792
807
|
|
|
793
|
-
def send(
|
|
808
|
+
def send(
|
|
809
|
+
self,
|
|
810
|
+
message: bytes,
|
|
811
|
+
*,
|
|
812
|
+
retries_on_read_timeout: int = 0,
|
|
813
|
+
) -> bytes:
|
|
794
814
|
hostname = get_hostname(self.endpoint)
|
|
795
815
|
if self.session is None:
|
|
796
816
|
self.session = self._build_session()
|
|
797
817
|
|
|
818
|
+
attempt = 0
|
|
819
|
+
while True:
|
|
798
820
|
# need to send an initial blank message to setup the security
|
|
799
821
|
# context required for encryption
|
|
800
|
-
if self.wrap_required:
|
|
822
|
+
if self.wrap_required and not self.encryption:
|
|
801
823
|
request = requests.Request("POST", self.endpoint, data=None)
|
|
802
824
|
prep_request = self.session.prepare_request(request)
|
|
803
|
-
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
self._send_request(prep_request)
|
|
828
|
+
except (requests.ReadTimeout, requests.ConnectTimeout) as e:
|
|
829
|
+
log.exception("%s during initial authentication request - attempt %d", (type(e).__name__, attempt))
|
|
830
|
+
if attempt == retries_on_read_timeout:
|
|
831
|
+
raise
|
|
832
|
+
|
|
833
|
+
attempt += 1
|
|
834
|
+
time.sleep(self.reconnection_backoff * (2**attempt))
|
|
835
|
+
continue
|
|
804
836
|
|
|
805
837
|
protocol = WinRMEncryption.SPNEGO
|
|
806
838
|
if isinstance(self.session.auth, HttpCredSSPAuth):
|
|
@@ -811,30 +843,42 @@ class _TransportHTTP(object):
|
|
|
811
843
|
|
|
812
844
|
self.encryption = WinRMEncryption(self.session.auth.contexts[hostname], protocol) # type: ignore[union-attr] # This should not happen
|
|
813
845
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
846
|
+
if log.isEnabledFor(logging.DEBUG):
|
|
847
|
+
log.debug("Sending message on attempt %d: %s" % (attempt, message.decode("utf-8")))
|
|
848
|
+
# for testing, keep commented out
|
|
849
|
+
# self._test_messages.append({"request": message.decode('utf-8'),
|
|
850
|
+
# "response": None})
|
|
851
|
+
|
|
852
|
+
headers = self.session.headers.copy() # type: ignore[attr-defined] # We cannot use copy.copy as it still is a ref to the original.
|
|
853
|
+
if self.wrap_required:
|
|
854
|
+
content_type, payload = self.encryption.wrap_message(message) # type: ignore[union-attr] # This should not happen
|
|
855
|
+
protocol = self.encryption.protocol if self.encryption else WinRMEncryption.SPNEGO
|
|
856
|
+
type_header = '%s;protocol="%s";boundary="Encrypted Boundary"' % (content_type, protocol)
|
|
857
|
+
headers.update(
|
|
858
|
+
{
|
|
859
|
+
"Content-Type": type_header,
|
|
860
|
+
"Content-Length": str(len(payload)),
|
|
861
|
+
}
|
|
862
|
+
)
|
|
863
|
+
else:
|
|
864
|
+
payload = message
|
|
865
|
+
headers["Content-Type"] = "application/soap+xml;charset=UTF-8"
|
|
866
|
+
|
|
867
|
+
request = requests.Request("POST", self.endpoint, data=payload, headers=headers)
|
|
868
|
+
prep_request = self.session.prepare_request(request)
|
|
869
|
+
try:
|
|
870
|
+
return self._send_request(prep_request)
|
|
871
|
+
except (requests.ReadTimeout, requests.ConnectTimeout) as e:
|
|
872
|
+
log.exception("%s during WSMan request - attempt %d", (type(e).__name__, attempt))
|
|
873
|
+
if attempt == retries_on_read_timeout:
|
|
874
|
+
raise
|
|
875
|
+
|
|
876
|
+
# On a failure the encryption state is invalidated and needs to
|
|
877
|
+
# be recreated after authentication is done.
|
|
878
|
+
self.encryption = None
|
|
834
879
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
return self._send_request(prep_request)
|
|
880
|
+
attempt += 1
|
|
881
|
+
time.sleep(self.reconnection_backoff * (2**attempt - 1))
|
|
838
882
|
|
|
839
883
|
def _send_request(self, request: requests.PreparedRequest) -> bytes:
|
|
840
884
|
response = self.session.send(request, timeout=(self.connection_timeout, self.read_timeout)) # type: ignore[union-attr] # This should not happen
|
|
@@ -878,7 +922,7 @@ class _TransportHTTP(object):
|
|
|
878
922
|
|
|
879
923
|
# get the env requests settings
|
|
880
924
|
session.trust_env = True
|
|
881
|
-
settings = session.merge_environment_settings(
|
|
925
|
+
settings = session.merge_environment_settings(
|
|
882
926
|
url=self.endpoint, proxies={}, stream=None, verify=None, cert=None
|
|
883
927
|
)
|
|
884
928
|
|
|
@@ -1,30 +1,44 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pypsrp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0rc1
|
|
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.9.1
|
|
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
|
|
@@ -235,7 +261,7 @@ configure a `WinRS` shell;
|
|
|
235
261
|
* `idle_time_out`: THe idle timeout in seconds of the shell
|
|
236
262
|
* `lifetime`: The total lifetime of the shell
|
|
237
263
|
* `name`: The name (description only) of the shell
|
|
238
|
-
* `no_profile`: Whether to create the shell with the user profile loaded or not
|
|
264
|
+
* `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
265
|
* `working_directory`: The default working directory of the created shell
|
|
240
266
|
|
|
241
267
|
`RunspacePool` is a shell used by the PSRP protocol, it is designed to be a
|
|
@@ -252,6 +278,7 @@ Here are the options that can be used to configure a `RunspacePool` shell;
|
|
|
252
278
|
* `min_runspaces`: The minimuum number of runspaces that a pool can hold, default is 1
|
|
253
279
|
* `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
280
|
* `session_key_timeout_ms`: The maximum time to wait for a session key transfer from the server
|
|
281
|
+
* `no_profile`: Do not load the user profile on the remote Runspace Pool
|
|
255
282
|
|
|
256
283
|
### Process
|
|
257
284
|
|
|
@@ -357,9 +384,8 @@ from pypsrp.wsman import WSMan
|
|
|
357
384
|
# creates a https connection with explicit kerberos auth and implicit credentials
|
|
358
385
|
wsman = WSMan("server", auth="kerberos", cert_validation=False))
|
|
359
386
|
|
|
360
|
-
with wsman, RunspacePool(wsman) as pool:
|
|
387
|
+
with wsman, RunspacePool(wsman) as pool, PowerShell(pool) as ps:
|
|
361
388
|
# execute 'Get-Process | Select-Object Name'
|
|
362
|
-
ps = PowerShell(pool)
|
|
363
389
|
ps.add_cmdlet("Get-Process").add_cmdlet("Select-Object").add_argument("Name")
|
|
364
390
|
output = ps.invoke()
|
|
365
391
|
|
|
@@ -390,6 +416,19 @@ with wsman, RunspacePool(wsman) as pool:
|
|
|
390
416
|
ps.invoke(["string", 1])
|
|
391
417
|
print(ps.output)
|
|
392
418
|
print(ps.streams.debug)
|
|
419
|
+
|
|
420
|
+
# It is possible to run the PowerShell pipeline again with invoke() but it
|
|
421
|
+
# needs to be explicitly closed first and the commands/streams optionally
|
|
422
|
+
# cleared if desired.
|
|
423
|
+
ps.close()
|
|
424
|
+
|
|
425
|
+
# Clears out ps.output and ps.streams to a blank value. Not required but
|
|
426
|
+
# nice if the output should be separate from a previous run
|
|
427
|
+
ps.clear_streams()
|
|
428
|
+
|
|
429
|
+
# Removes all existing commands. Not required but needed if re-using the
|
|
430
|
+
# same pipeline with a different set of commands
|
|
431
|
+
ps.clear_commands()
|
|
393
432
|
```
|
|
394
433
|
|
|
395
434
|
|
|
@@ -443,17 +482,18 @@ information and should only be used for debugging purposes._
|
|
|
443
482
|
## Testing
|
|
444
483
|
|
|
445
484
|
Any changes are more than welcome in pull request form, you can run the current
|
|
446
|
-
test suite with
|
|
485
|
+
test suite with:
|
|
447
486
|
|
|
448
487
|
```bash
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
488
|
+
pip install -e .[dev]
|
|
489
|
+
|
|
490
|
+
python -m pytest \
|
|
491
|
+
tests/tests_pypsrp \
|
|
492
|
+
--verbose \
|
|
493
|
+
--junitxml junit/test-results.xml \
|
|
494
|
+
--cov pypsrp \
|
|
495
|
+
--cov-report xml \
|
|
496
|
+
--cov-report term-missing
|
|
457
497
|
```
|
|
458
498
|
|
|
459
499
|
A lot of the tests either simulate a remote Windows host but you can also run a
|
|
@@ -523,5 +563,3 @@ tests.
|
|
|
523
563
|
* Add Ansible playbook for better integration tests
|
|
524
564
|
* Improved serialization between Python and .NET objects
|
|
525
565
|
* Live interactive console for PSRP
|
|
526
|
-
|
|
527
|
-
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
pypsrp/__init__.py,sha256=9aButMuBNIEph0LfHsodNd8uSS7XKZj9IPHJsPd8hjY,958
|
|
2
|
+
pypsrp/_utils.py,sha256=y9sezXQPl0vXnr1beyZ8xcn-Q_zdg_7cg_lPk9QmjVQ,3540
|
|
3
|
+
pypsrp/client.py,sha256=iJERbEXwwE4wswpqAL0Dcz8ACqLvXUT9Dcl_Qqa3830,13892
|
|
4
|
+
pypsrp/complex_objects.py,sha256=PkJ1F1Byw5RI_Vpfy1KfcMFJdOfioOSsIbi50S_fZ3g,62666
|
|
5
|
+
pypsrp/encryption.py,sha256=4wOontH-eF8BBxTyHTX9iAF0uIM6m-z3N9poatspP1A,3870
|
|
6
|
+
pypsrp/exceptions.py,sha256=SWdQ9UAPOIwr7B4twGqDoXLCEtXFK_5u3PYIVLvEIbA,4034
|
|
7
|
+
pypsrp/host.py,sha256=XQgnkwzsZprNmAr1TPJpEBmj3xRSWFrYQFSIi870aNE,45065
|
|
8
|
+
pypsrp/messages.py,sha256=mCuie1ywOQmkXltISPvrb6ITS6GUA0oSld2jfNLdBf4,34169
|
|
9
|
+
pypsrp/negotiate.py,sha256=YU6mGuDd8krOapiRcY6qID8v-GMDbIypCYUc7eTkzmw,10952
|
|
10
|
+
pypsrp/powershell.py,sha256=SjmD8LfbMtyoGfXpQmSV10ded4FO14J8vM5Qn37R0dI,67359
|
|
11
|
+
pypsrp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
pypsrp/serializer.py,sha256=49rBxfo3fh511mU3uzoK9vFvIOrBtyX3J-CUugbezc0,33767
|
|
13
|
+
pypsrp/shell.py,sha256=EoGtElIYvNFPjBvAIMuLaHPyX0NgGtBN265Vt6YqkhQ,16748
|
|
14
|
+
pypsrp/wsman.py,sha256=V90G93O2bi9fPP09fPhyheYN4_G_8zo_Sxo1ea2pqPI,47500
|
|
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.9.0rc1.dist-info/licenses/LICENSE,sha256=8uJLgs8Wb_Us_8XaTvOOn7Yf0dwzFi9pcFHVxfgJKoE,1079
|
|
19
|
+
pypsrp-0.9.0rc1.dist-info/METADATA,sha256=RsbvzIn44QmLDG7Pi5QCULyxtJ8Z2kZByhI7dkU6Fmo,23485
|
|
20
|
+
pypsrp-0.9.0rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
21
|
+
pypsrp-0.9.0rc1.dist-info/top_level.txt,sha256=9sFQD0PMIG07xbaR4j-1Fq7NaD9kwInn_HTc3Gqznq0,7
|
|
22
|
+
pypsrp-0.9.0rc1.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
|