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 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, "")