monkeytoolbox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CHANGELOG.md +15 -0
- README.md +14 -0
- monkeytoolbox/__init__.py +34 -0
- monkeytoolbox/code_utils.py +160 -0
- monkeytoolbox/decorators.py +66 -0
- monkeytoolbox/environment.py +38 -0
- monkeytoolbox/file_utils.py +78 -0
- monkeytoolbox/network_utils.py +53 -0
- monkeytoolbox/secure_directory.py +88 -0
- monkeytoolbox/secure_file.py +85 -0
- monkeytoolbox/threading.py +140 -0
- monkeytoolbox/windows_permissions.py +52 -0
- monkeytoolbox-0.1.0.dist-info/LICENSE +677 -0
- monkeytoolbox-0.1.0.dist-info/METADATA +36 -0
- monkeytoolbox-0.1.0.dist-info/RECORD +16 -0
- monkeytoolbox-0.1.0.dist-info/WHEEL +4 -0
CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
All notable changes to this project will be documented in this
|
|
3
|
+
file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a
|
|
6
|
+
Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
|
|
7
|
+
the [PEP 440 version scheme](https://peps.python.org/pep-0440/#version-scheme).
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
### Added
|
|
12
|
+
### Changed
|
|
13
|
+
### Fixed
|
|
14
|
+
### Removed
|
|
15
|
+
### Security
|
README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# monkeytoolbox
|
|
2
|
+
|
|
3
|
+
This project contains a collection of convenience functions, classes, and
|
|
4
|
+
utilities written in Python. It is used mainly by [Infection
|
|
5
|
+
Monkey](https://github.com/guardicore/monkey).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
`pip install monkeytoolbox`
|
|
9
|
+
|
|
10
|
+
## Running tests
|
|
11
|
+
```
|
|
12
|
+
$> poetry install
|
|
13
|
+
$> poetry run pytest
|
|
14
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .environment import get_os, get_hardware_id
|
|
2
|
+
from .decorators import request_cache
|
|
3
|
+
from .code_utils import (
|
|
4
|
+
apply_filters,
|
|
5
|
+
queue_to_list,
|
|
6
|
+
del_key,
|
|
7
|
+
insecure_generate_random_string,
|
|
8
|
+
secure_generate_random_string,
|
|
9
|
+
PeriodicCaller
|
|
10
|
+
)
|
|
11
|
+
from .file_utils import (
|
|
12
|
+
append_bytes,
|
|
13
|
+
make_fileobj_copy,
|
|
14
|
+
InvalidPath,
|
|
15
|
+
expand_path,
|
|
16
|
+
get_text_file_contents,
|
|
17
|
+
get_all_regular_files_in_directory,
|
|
18
|
+
get_binary_io_sha256_hash
|
|
19
|
+
)
|
|
20
|
+
from .secure_directory import create_secure_directory
|
|
21
|
+
from .secure_file import open_new_securely_permissioned_file
|
|
22
|
+
from .threading import (
|
|
23
|
+
ThreadSafeIterator,
|
|
24
|
+
InterruptableThreadMixin,
|
|
25
|
+
interruptible_function,
|
|
26
|
+
interruptible_iter,
|
|
27
|
+
create_daemon_thread,
|
|
28
|
+
run_worker_threads
|
|
29
|
+
)
|
|
30
|
+
from .network_utils import (
|
|
31
|
+
port_is_used,
|
|
32
|
+
get_network_interfaces,
|
|
33
|
+
get_my_ip_addresses
|
|
34
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import queue
|
|
3
|
+
import random
|
|
4
|
+
import secrets
|
|
5
|
+
import string
|
|
6
|
+
from threading import Event, Thread
|
|
7
|
+
from typing import Any, Callable, Iterable, List, MutableMapping, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def apply_filters(filters: Iterable[Callable[[T], bool]], iterable: Iterable[T]) -> Iterable[T]:
|
|
15
|
+
"""
|
|
16
|
+
Applies multiple filters to an iterable
|
|
17
|
+
|
|
18
|
+
:param filters: An iterable of filters to be applied to the iterable
|
|
19
|
+
:param iterable: An iterable to be filtered
|
|
20
|
+
:return: A new iterable with the filters applied
|
|
21
|
+
"""
|
|
22
|
+
filtered_iterable = iterable
|
|
23
|
+
for f in filters:
|
|
24
|
+
filtered_iterable = filter(f, filtered_iterable)
|
|
25
|
+
|
|
26
|
+
return filtered_iterable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def queue_to_list(q: queue.Queue) -> List[Any]:
|
|
30
|
+
list_ = []
|
|
31
|
+
try:
|
|
32
|
+
while True:
|
|
33
|
+
list_.append(q.get_nowait())
|
|
34
|
+
except queue.Empty:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
return list_
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def del_key(mapping: MutableMapping[T, Any], key: T):
|
|
41
|
+
"""
|
|
42
|
+
Delete a key from a mapping.
|
|
43
|
+
|
|
44
|
+
Unlike the `del` keyword, this function does not raise a KeyError
|
|
45
|
+
if the key does not exist.
|
|
46
|
+
|
|
47
|
+
:param mapping: A mapping from which a key will be deleted
|
|
48
|
+
:param key: A key to delete from `mapping`
|
|
49
|
+
"""
|
|
50
|
+
mapping.pop(key, None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def insecure_generate_random_string(
|
|
54
|
+
n: int, character_set: str = string.ascii_letters + string.digits
|
|
55
|
+
) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Generate a random string
|
|
58
|
+
|
|
59
|
+
This function generates a random string. The length is specified by the user. The character set
|
|
60
|
+
can optionally be specified by the user.
|
|
61
|
+
|
|
62
|
+
WARNING: This function is not safe to use for cryptographic purposes.
|
|
63
|
+
|
|
64
|
+
:param n: The desired number of characters in the random string
|
|
65
|
+
:param character set: The set of characters that may be included in the random string, defaults
|
|
66
|
+
to alphanumerics
|
|
67
|
+
"""
|
|
68
|
+
return _generate_random_string(random.choices, n, character_set) # noqa: DUO102
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def secure_generate_random_string(
|
|
72
|
+
n: int, character_set: str = string.ascii_letters + string.digits
|
|
73
|
+
) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Generate a random string
|
|
76
|
+
|
|
77
|
+
This function generates a random string. The length is specified by the user. The character set
|
|
78
|
+
can optionally be specified by the user.
|
|
79
|
+
|
|
80
|
+
This function is safe to use for cryptographic purposes.
|
|
81
|
+
|
|
82
|
+
WARNING: This function may block if the system does not have sufficient entropy.
|
|
83
|
+
|
|
84
|
+
:param n: The desired number of characters in the random string
|
|
85
|
+
:param character set: The set of characters that may be included in the random string, defaults
|
|
86
|
+
to alphanumerics
|
|
87
|
+
"""
|
|
88
|
+
return _generate_random_string(secrets.SystemRandom().choices, n, character_set)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Note: Trying to typehint the rng parameter is more trouble than it's worth
|
|
92
|
+
def _generate_random_string(rng, n: int, character_set: str) -> str:
|
|
93
|
+
return "".join(rng(character_set, k=n))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PeriodicCaller:
|
|
97
|
+
"""
|
|
98
|
+
Periodically calls a function
|
|
99
|
+
|
|
100
|
+
Given a callable and a period, this component calls the callback periodically. The calls can
|
|
101
|
+
occur in the background by calling the `start()` method, or in the foreground by calling the
|
|
102
|
+
`run()` method. Note that this component is susceptible to "timer creep". In other words, the
|
|
103
|
+
callable is not called every `period` seconds. It is called `period` seconds after the last call
|
|
104
|
+
completes. This prevents multiple calls to the callback occurring concurrently.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, callback: Callable[[], None], period: float, name: Optional[str] = None):
|
|
108
|
+
"""
|
|
109
|
+
:param callback: A callable to be called periodically
|
|
110
|
+
:param period: The time to wait between calls of `callback`.
|
|
111
|
+
:param name: A human-readable name for this caller that will be used in debug logging
|
|
112
|
+
"""
|
|
113
|
+
self._callback = callback
|
|
114
|
+
self._period = period
|
|
115
|
+
|
|
116
|
+
self._name = f"PeriodicCaller-{callback.__name__}" if name is None else name
|
|
117
|
+
|
|
118
|
+
self._stop = Event()
|
|
119
|
+
self._thread: Optional[Thread] = None
|
|
120
|
+
|
|
121
|
+
def start(self):
|
|
122
|
+
"""
|
|
123
|
+
Periodically call the callback in the background
|
|
124
|
+
"""
|
|
125
|
+
logger.debug(f"Starting {self._name}")
|
|
126
|
+
|
|
127
|
+
self._stop.clear()
|
|
128
|
+
self._thread = Thread(daemon=True, name=self._name, target=self.run)
|
|
129
|
+
self._thread.start()
|
|
130
|
+
|
|
131
|
+
def run(self):
|
|
132
|
+
"""
|
|
133
|
+
Periodically call the callback and block until `stop()` is called
|
|
134
|
+
"""
|
|
135
|
+
logger.debug(f"Successfully started {self._name}")
|
|
136
|
+
|
|
137
|
+
while not self._stop.is_set():
|
|
138
|
+
self._callback()
|
|
139
|
+
self._stop.wait(self._period)
|
|
140
|
+
|
|
141
|
+
logger.debug(f"Successfully stopped {self._name}")
|
|
142
|
+
|
|
143
|
+
def stop(self, timeout: Optional[float] = None):
|
|
144
|
+
"""
|
|
145
|
+
Stop this component from making any further calls
|
|
146
|
+
|
|
147
|
+
When the timeout argument is not present or None, the operation will block until the
|
|
148
|
+
PeriodicCaller stops.
|
|
149
|
+
|
|
150
|
+
:param timeout: The number of seconds to wait for this component to stop
|
|
151
|
+
"""
|
|
152
|
+
logger.debug(f"Stopping {self._name}")
|
|
153
|
+
|
|
154
|
+
self._stop.set()
|
|
155
|
+
|
|
156
|
+
if self._thread is not None:
|
|
157
|
+
self._thread.join(timeout=timeout)
|
|
158
|
+
|
|
159
|
+
if self._thread.is_alive():
|
|
160
|
+
logger.warning(f"Timed out waiting for {self._name} to stop")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from egg_timer import EggTimer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def request_cache(ttl: float):
|
|
9
|
+
"""
|
|
10
|
+
This is a decorator that allows a single response of a function to be cached with an expiration
|
|
11
|
+
time (TTL). The first call to the function is executed and the response is cached. Subsequent
|
|
12
|
+
calls to the function result in the cached value being returned until the TTL elapses. Once the
|
|
13
|
+
TTL elapses, the cache is considered stale and the decorated function will be called, its
|
|
14
|
+
response cached, and the TTL reset.
|
|
15
|
+
|
|
16
|
+
An example usage of this decorator is to wrap a function that makes frequent slow calls to an
|
|
17
|
+
external resource, such as an HTTP request to a remote endpoint. If the most up-to-date
|
|
18
|
+
information is not need, this decorator provides a simple way to cache the response for a
|
|
19
|
+
certain amount of time.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
@request_cache(600)
|
|
23
|
+
def raining_outside():
|
|
24
|
+
return requests.get(f"https://weather.service.api/check_for_rain/{MY_ZIP_CODE}")
|
|
25
|
+
|
|
26
|
+
The request cache can be manually cleared if desired:
|
|
27
|
+
status_1 = raining_outside()
|
|
28
|
+
status_2 = raining_outside()
|
|
29
|
+
raining_outside.clear_cache()
|
|
30
|
+
status_3 = raining_outside()
|
|
31
|
+
|
|
32
|
+
assert status_1 == status_2
|
|
33
|
+
assert status_1 != status_3
|
|
34
|
+
|
|
35
|
+
:param ttl: The time-to-live in seconds for the cached return value
|
|
36
|
+
:return: The return value of the decorated function, or the cached return value if the TTL has
|
|
37
|
+
not elapsed.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def decorator(fn: Callable) -> Callable:
|
|
41
|
+
cached_value = None
|
|
42
|
+
timer = EggTimer()
|
|
43
|
+
lock = threading.Lock()
|
|
44
|
+
|
|
45
|
+
@wraps(fn)
|
|
46
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
47
|
+
nonlocal cached_value, timer, lock
|
|
48
|
+
|
|
49
|
+
with lock:
|
|
50
|
+
if timer.is_expired():
|
|
51
|
+
cached_value = fn(*args, **kwargs)
|
|
52
|
+
timer.set(ttl)
|
|
53
|
+
|
|
54
|
+
return cached_value
|
|
55
|
+
|
|
56
|
+
def clear_cache():
|
|
57
|
+
nonlocal timer, lock
|
|
58
|
+
|
|
59
|
+
with lock:
|
|
60
|
+
timer.set(0)
|
|
61
|
+
|
|
62
|
+
wrapper.clear_cache = clear_cache # type: ignore [attr-defined]
|
|
63
|
+
|
|
64
|
+
return wrapper
|
|
65
|
+
|
|
66
|
+
return decorator
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import uuid
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
|
|
5
|
+
from monkeytypes import HardwareID, OperatingSystem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_os() -> OperatingSystem:
|
|
9
|
+
if platform.system() == "Windows":
|
|
10
|
+
return OperatingSystem.WINDOWS
|
|
11
|
+
|
|
12
|
+
return OperatingSystem.LINUX
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_hardware_id() -> HardwareID:
|
|
16
|
+
if get_os() == OperatingSystem.WINDOWS:
|
|
17
|
+
return _get_hardware_id_windows()
|
|
18
|
+
|
|
19
|
+
return _get_hardware_id_linux()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_hardware_id_windows() -> HardwareID:
|
|
23
|
+
return uuid.getnode()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_hardware_id_linux() -> HardwareID:
|
|
27
|
+
# Different compile-time parameters for Python on Linux can cause `uuid.getnode()` to yield
|
|
28
|
+
# different results. Calling `uuid._ip_getnode()` directly seems to be the most reliable way to
|
|
29
|
+
# get consistend IDs across different Python binaries. See
|
|
30
|
+
# https://github.com/guardicore/monkey/issues/3176 for more details
|
|
31
|
+
|
|
32
|
+
with suppress(AttributeError):
|
|
33
|
+
machine_id = uuid._ip_getnode() # type: ignore [attr-defined]
|
|
34
|
+
|
|
35
|
+
if machine_id is None:
|
|
36
|
+
machine_id = uuid.getnode()
|
|
37
|
+
|
|
38
|
+
return machine_id
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import BinaryIO, Iterable
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MAX_BLOCK_SIZE = 65536
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvalidPath(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def expand_path(path: str) -> Path:
|
|
20
|
+
if not path:
|
|
21
|
+
raise InvalidPath("Empty path provided")
|
|
22
|
+
|
|
23
|
+
return Path(os.path.expandvars(os.path.expanduser(path)))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_binary_io_sha256_hash(binary: BinaryIO) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Calculates sha256 hash from a file-like object
|
|
29
|
+
|
|
30
|
+
:param binary: file-like object from which we calculate the hash
|
|
31
|
+
:return: sha256 hash from the file-like object
|
|
32
|
+
"""
|
|
33
|
+
sha256 = hashlib.sha256()
|
|
34
|
+
for block in iter(lambda: binary.read(MAX_BLOCK_SIZE), b""):
|
|
35
|
+
sha256.update(block)
|
|
36
|
+
|
|
37
|
+
return sha256.hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_all_regular_files_in_directory(dir_path: Path) -> Iterable[Path]:
|
|
41
|
+
return filter(lambda f: f.is_file(), dir_path.iterdir())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_text_file_contents(file_path: Path) -> str:
|
|
45
|
+
with open(file_path, "rt") as f:
|
|
46
|
+
file_contents = f.read()
|
|
47
|
+
return file_contents
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def make_fileobj_copy(src: BinaryIO) -> BinaryIO:
|
|
51
|
+
"""
|
|
52
|
+
Creates a file-like object that is a copy of the provided file-like object
|
|
53
|
+
|
|
54
|
+
The source file-like object is reset to position 0 and a copy is made. Both the source file and
|
|
55
|
+
the copy are reset to position 0 before returning.
|
|
56
|
+
|
|
57
|
+
:param src: A file-like object to copy
|
|
58
|
+
:return: A file-like object that is a copy of the provided file-like object
|
|
59
|
+
"""
|
|
60
|
+
dst = io.BytesIO()
|
|
61
|
+
|
|
62
|
+
src.seek(0)
|
|
63
|
+
shutil.copyfileobj(src, dst)
|
|
64
|
+
|
|
65
|
+
src.seek(0)
|
|
66
|
+
dst.seek(0)
|
|
67
|
+
|
|
68
|
+
return dst
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def append_bytes(file: BinaryIO, bytes_to_append: bytes) -> BinaryIO:
|
|
72
|
+
starting_position = file.tell()
|
|
73
|
+
|
|
74
|
+
file.seek(0, io.SEEK_END)
|
|
75
|
+
file.write(bytes_to_append)
|
|
76
|
+
file.seek(starting_position, io.SEEK_SET)
|
|
77
|
+
|
|
78
|
+
return file
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
from ipaddress import IPv4Address, IPv4Interface
|
|
3
|
+
from typing import Iterable, List, Optional, Sequence
|
|
4
|
+
|
|
5
|
+
import ifaddr
|
|
6
|
+
import psutil
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_my_ip_addresses() -> Sequence[IPv4Address]:
|
|
10
|
+
return [interface.ip for interface in get_network_interfaces()]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_network_interfaces() -> List[IPv4Interface]:
|
|
14
|
+
local_interfaces = []
|
|
15
|
+
for adapter in ifaddr.get_adapters():
|
|
16
|
+
for ip in _select_ipv4_ips(adapter.ips):
|
|
17
|
+
interface = ipaddress.IPv4Interface(f"{ip.ip}/{ip.network_prefix}")
|
|
18
|
+
if not interface.ip.is_link_local:
|
|
19
|
+
local_interfaces.append(interface)
|
|
20
|
+
|
|
21
|
+
return local_interfaces
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _select_ipv4_ips(ips: Iterable[ifaddr.IP]) -> Iterable[ifaddr.IP]:
|
|
25
|
+
return filter(lambda ip: _is_ipv4(ip) and ip.ip != "127.0.0.1", ips)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_ipv4(ip: ifaddr.IP) -> bool:
|
|
29
|
+
# In ifaddr, IPv4 addresses are strings, while IPv6 addresses are tuples
|
|
30
|
+
return type(ip.ip) is str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def port_is_used(
|
|
34
|
+
port: int,
|
|
35
|
+
ip_addresses: Optional[Sequence[IPv4Address]],
|
|
36
|
+
) -> bool:
|
|
37
|
+
connections = get_connections([port], ip_addresses)
|
|
38
|
+
return len(connections) > 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_connections(
|
|
42
|
+
ports: Optional[Sequence[int]] = None,
|
|
43
|
+
ip_addresses: Optional[Sequence[IPv4Address]] = None,
|
|
44
|
+
) -> List[psutil._common.sconn]:
|
|
45
|
+
connections = psutil.net_connections()
|
|
46
|
+
if ports:
|
|
47
|
+
connections = [connection for connection in connections if connection.laddr.port in ports]
|
|
48
|
+
if ip_addresses:
|
|
49
|
+
ip_addresses_ = list(map(str, ip_addresses))
|
|
50
|
+
connections = [
|
|
51
|
+
connection for connection in connections if connection.laddr.ip in ip_addresses_
|
|
52
|
+
]
|
|
53
|
+
return connections
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import stat
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from . import get_os
|
|
7
|
+
|
|
8
|
+
from monkeytypes import OperatingSystem
|
|
9
|
+
|
|
10
|
+
if get_os() == OperatingSystem.WINDOWS:
|
|
11
|
+
import win32file
|
|
12
|
+
import win32security
|
|
13
|
+
|
|
14
|
+
from .windows_permissions import get_security_descriptor_for_owner_only_permissions
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FailedDirectoryCreationError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_secure_directory(path: Path):
|
|
24
|
+
if get_os() == OperatingSystem.WINDOWS:
|
|
25
|
+
make_existing_directory_secure_for_os = _make_existing_directory_secure_windows
|
|
26
|
+
create_secure_directory_for_os = _create_secure_directory_windows
|
|
27
|
+
else:
|
|
28
|
+
make_existing_directory_secure_for_os = _make_existing_directory_secure_linux
|
|
29
|
+
create_secure_directory_for_os = _create_secure_directory_linux
|
|
30
|
+
|
|
31
|
+
if path.exists():
|
|
32
|
+
_check_path_is_directory(path)
|
|
33
|
+
_make_existing_directory_secure(make_existing_directory_secure_for_os, path)
|
|
34
|
+
else:
|
|
35
|
+
_create_secure_directory(create_secure_directory_for_os, path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_path_is_directory(path: Path):
|
|
39
|
+
if not path.is_dir():
|
|
40
|
+
raise FailedDirectoryCreationError(
|
|
41
|
+
f'The path "{path}" already exists and is not a directory'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger.info(f"A directory already exists at {path}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _make_existing_directory_secure(fn_for_os: Callable, path: Path):
|
|
48
|
+
try:
|
|
49
|
+
fn_for_os(path)
|
|
50
|
+
except Exception as err:
|
|
51
|
+
message = (
|
|
52
|
+
"An error occured while changing the existing directory's permissions"
|
|
53
|
+
f"to be secure: {str(err)}"
|
|
54
|
+
)
|
|
55
|
+
logger.exception(message)
|
|
56
|
+
raise FailedDirectoryCreationError(err)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _create_secure_directory(fn_for_os: Callable, path: Path):
|
|
60
|
+
try:
|
|
61
|
+
fn_for_os(path)
|
|
62
|
+
except Exception as err:
|
|
63
|
+
message = f"Could not create a secure directory at {path}: {str(err)}"
|
|
64
|
+
logger.error(message)
|
|
65
|
+
raise FailedDirectoryCreationError(message)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_existing_directory_secure_windows(path: Path):
|
|
69
|
+
security_descriptor = get_security_descriptor_for_owner_only_permissions()
|
|
70
|
+
win32security.SetFileSecurity(
|
|
71
|
+
str(path), win32security.DACL_SECURITY_INFORMATION, security_descriptor
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _create_secure_directory_windows(path: Path):
|
|
76
|
+
security_attributes = win32security.SECURITY_ATTRIBUTES()
|
|
77
|
+
security_attributes.SECURITY_DESCRIPTOR = (
|
|
78
|
+
get_security_descriptor_for_owner_only_permissions()
|
|
79
|
+
)
|
|
80
|
+
win32file.CreateDirectory(str(path), security_attributes)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _make_existing_directory_secure_linux(path: Path):
|
|
84
|
+
path.chmod(mode=stat.S_IRWXU)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _create_secure_directory_linux(path: Path):
|
|
88
|
+
path.mkdir(mode=stat.S_IRWXU)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import stat
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Generator
|
|
6
|
+
|
|
7
|
+
from . import get_os
|
|
8
|
+
from monkeytypes import OperatingSystem
|
|
9
|
+
|
|
10
|
+
if get_os() == OperatingSystem.WINDOWS:
|
|
11
|
+
import win32file
|
|
12
|
+
import win32job
|
|
13
|
+
import win32security
|
|
14
|
+
|
|
15
|
+
from .windows_permissions import get_security_descriptor_for_owner_only_permissions
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def open_new_securely_permissioned_file(path: str, mode: str = "w") -> Generator:
|
|
23
|
+
if get_os() == OperatingSystem.WINDOWS:
|
|
24
|
+
# TODO: Switch from string to Path object to avoid this hack.
|
|
25
|
+
fd = _get_file_descriptor_for_new_secure_file_windows(str(path))
|
|
26
|
+
else:
|
|
27
|
+
fd = _get_file_descriptor_for_new_secure_file_linux(path)
|
|
28
|
+
|
|
29
|
+
with open(fd, mode) as f:
|
|
30
|
+
yield f
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_file_descriptor_for_new_secure_file_linux(path: str) -> int:
|
|
34
|
+
try:
|
|
35
|
+
mode = stat.S_IRUSR | stat.S_IWUSR
|
|
36
|
+
flags = (
|
|
37
|
+
os.O_RDWR | os.O_CREAT | os.O_EXCL
|
|
38
|
+
) # read/write, create new, throw error if file exists
|
|
39
|
+
fd = os.open(path, flags, mode)
|
|
40
|
+
|
|
41
|
+
return fd
|
|
42
|
+
|
|
43
|
+
except Exception as ex:
|
|
44
|
+
logger.error(f'Could not create a file at "{path}": {str(ex)}')
|
|
45
|
+
raise ex
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_file_descriptor_for_new_secure_file_windows(path: str) -> int:
|
|
49
|
+
try:
|
|
50
|
+
file_access = win32file.GENERIC_READ | win32file.GENERIC_WRITE
|
|
51
|
+
|
|
52
|
+
# Enables other processes to open this file with read-only access.
|
|
53
|
+
# Attempts by other processes to open the file for writing while this
|
|
54
|
+
# process still holds it open will fail.
|
|
55
|
+
file_sharing = win32file.FILE_SHARE_READ
|
|
56
|
+
|
|
57
|
+
security_attributes = win32security.SECURITY_ATTRIBUTES()
|
|
58
|
+
security_attributes.SECURITY_DESCRIPTOR = (
|
|
59
|
+
get_security_descriptor_for_owner_only_permissions()
|
|
60
|
+
)
|
|
61
|
+
file_creation = win32file.CREATE_NEW # fails if file exists
|
|
62
|
+
file_attributes = win32file.FILE_FLAG_BACKUP_SEMANTICS
|
|
63
|
+
|
|
64
|
+
handle = win32file.CreateFile(
|
|
65
|
+
path,
|
|
66
|
+
file_access,
|
|
67
|
+
file_sharing,
|
|
68
|
+
security_attributes,
|
|
69
|
+
file_creation,
|
|
70
|
+
file_attributes,
|
|
71
|
+
_get_null_value_for_win32(),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
detached_handle = handle.Detach()
|
|
75
|
+
|
|
76
|
+
return win32file._open_osfhandle(detached_handle, os.O_RDWR)
|
|
77
|
+
|
|
78
|
+
except Exception as ex:
|
|
79
|
+
logger.error(f'Could not create a file at "{path}": {str(ex)}')
|
|
80
|
+
raise ex
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_null_value_for_win32():
|
|
84
|
+
# https://stackoverflow.com/questions/46800142/in-python-with-pywin32-win32job-the-createjobobject-function-how-do-i-pass-nu # noqa: E501
|
|
85
|
+
return win32job.CreateJobObject(None, "")
|