pyoaev 1.18.20__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.
Files changed (72) hide show
  1. docs/conf.py +65 -0
  2. pyoaev/__init__.py +26 -0
  3. pyoaev/_version.py +6 -0
  4. pyoaev/apis/__init__.py +20 -0
  5. pyoaev/apis/attack_pattern.py +28 -0
  6. pyoaev/apis/collector.py +29 -0
  7. pyoaev/apis/cve.py +18 -0
  8. pyoaev/apis/document.py +29 -0
  9. pyoaev/apis/endpoint.py +38 -0
  10. pyoaev/apis/inject.py +29 -0
  11. pyoaev/apis/inject_expectation/__init__.py +1 -0
  12. pyoaev/apis/inject_expectation/inject_expectation.py +118 -0
  13. pyoaev/apis/inject_expectation/model/__init__.py +7 -0
  14. pyoaev/apis/inject_expectation/model/expectation.py +173 -0
  15. pyoaev/apis/inject_expectation_trace.py +36 -0
  16. pyoaev/apis/injector.py +26 -0
  17. pyoaev/apis/injector_contract.py +56 -0
  18. pyoaev/apis/inputs/__init__.py +0 -0
  19. pyoaev/apis/inputs/search.py +72 -0
  20. pyoaev/apis/kill_chain_phase.py +22 -0
  21. pyoaev/apis/me.py +17 -0
  22. pyoaev/apis/organization.py +11 -0
  23. pyoaev/apis/payload.py +27 -0
  24. pyoaev/apis/security_platform.py +33 -0
  25. pyoaev/apis/tag.py +19 -0
  26. pyoaev/apis/team.py +25 -0
  27. pyoaev/apis/user.py +31 -0
  28. pyoaev/backends/__init__.py +14 -0
  29. pyoaev/backends/backend.py +136 -0
  30. pyoaev/backends/protocol.py +32 -0
  31. pyoaev/base.py +320 -0
  32. pyoaev/client.py +596 -0
  33. pyoaev/configuration/__init__.py +3 -0
  34. pyoaev/configuration/configuration.py +188 -0
  35. pyoaev/configuration/sources.py +44 -0
  36. pyoaev/contracts/__init__.py +5 -0
  37. pyoaev/contracts/contract_builder.py +44 -0
  38. pyoaev/contracts/contract_config.py +292 -0
  39. pyoaev/contracts/contract_utils.py +22 -0
  40. pyoaev/contracts/variable_helper.py +124 -0
  41. pyoaev/daemons/__init__.py +4 -0
  42. pyoaev/daemons/base_daemon.py +131 -0
  43. pyoaev/daemons/collector_daemon.py +91 -0
  44. pyoaev/exceptions.py +219 -0
  45. pyoaev/helpers.py +451 -0
  46. pyoaev/mixins.py +242 -0
  47. pyoaev/signatures/__init__.py +0 -0
  48. pyoaev/signatures/signature_match.py +12 -0
  49. pyoaev/signatures/signature_type.py +51 -0
  50. pyoaev/signatures/types.py +17 -0
  51. pyoaev/utils.py +211 -0
  52. pyoaev-1.18.20.dist-info/METADATA +134 -0
  53. pyoaev-1.18.20.dist-info/RECORD +72 -0
  54. pyoaev-1.18.20.dist-info/WHEEL +5 -0
  55. pyoaev-1.18.20.dist-info/licenses/LICENSE +201 -0
  56. pyoaev-1.18.20.dist-info/top_level.txt +4 -0
  57. scripts/release.py +127 -0
  58. test/__init__.py +0 -0
  59. test/apis/__init__.py +0 -0
  60. test/apis/expectation/__init__.py +0 -0
  61. test/apis/expectation/test_expectation.py +338 -0
  62. test/apis/injector_contract/__init__.py +0 -0
  63. test/apis/injector_contract/test_injector_contract.py +58 -0
  64. test/configuration/__init__.py +0 -0
  65. test/configuration/test_configuration.py +257 -0
  66. test/configuration/test_sources.py +69 -0
  67. test/daemons/__init__.py +0 -0
  68. test/daemons/test_base_daemon.py +109 -0
  69. test/daemons/test_collector_daemon.py +39 -0
  70. test/signatures/__init__.py +0 -0
  71. test/signatures/test_signature_match.py +25 -0
  72. test/signatures/test_signature_type.py +57 -0
@@ -0,0 +1,124 @@
1
+ from pyoaev.contracts.contract_utils import (
2
+ ContractCardinality,
3
+ ContractVariable,
4
+ VariableType,
5
+ )
6
+
7
+ USER = "user"
8
+ EXERCISE = "exercise"
9
+ TEAMS = "teams"
10
+ COMCHECK = "comcheck"
11
+ PLAYER_URI = "player_uri"
12
+ CHALLENGES_URI = "challenges_uri"
13
+ SCOREBOARD_URI = "scoreboard_uri"
14
+ LESSONS_URI = "lessons_uri"
15
+
16
+
17
+ class VariableHelper:
18
+ @staticmethod
19
+ def user_variable():
20
+ return ContractVariable(
21
+ key=USER,
22
+ label="User that will receive the injection",
23
+ type=VariableType.String,
24
+ cardinality=ContractCardinality.One,
25
+ children=[
26
+ ContractVariable(
27
+ key=USER + ".id",
28
+ label="Id of the user in the platform",
29
+ type=VariableType.String,
30
+ cardinality=ContractCardinality.One,
31
+ ),
32
+ ContractVariable(
33
+ key=USER + ".email",
34
+ label="Email of the user",
35
+ type=VariableType.String,
36
+ cardinality=ContractCardinality.One,
37
+ ),
38
+ ContractVariable(
39
+ key=USER + ".firstname",
40
+ label="Firstname of the user",
41
+ type=VariableType.String,
42
+ cardinality=ContractCardinality.One,
43
+ ),
44
+ ContractVariable(
45
+ key=USER + ".lastname",
46
+ label="Lastname of the user",
47
+ type=VariableType.String,
48
+ cardinality=ContractCardinality.One,
49
+ ),
50
+ ContractVariable(
51
+ key=USER + ".lang",
52
+ label="Lang of the user",
53
+ type=VariableType.String,
54
+ cardinality=ContractCardinality.One,
55
+ ),
56
+ ],
57
+ )
58
+
59
+ @staticmethod
60
+ def exercise_variable():
61
+ return ContractVariable(
62
+ key=EXERCISE,
63
+ label="Exercise of the current injection",
64
+ type=VariableType.Object,
65
+ cardinality=ContractCardinality.One,
66
+ children=[
67
+ ContractVariable(
68
+ key=EXERCISE + ".id",
69
+ label="Id of the exercise in the platform",
70
+ type=VariableType.String,
71
+ cardinality=ContractCardinality.One,
72
+ ),
73
+ ContractVariable(
74
+ key=EXERCISE + ".name",
75
+ label="Name of the exercise",
76
+ type=VariableType.String,
77
+ cardinality=ContractCardinality.One,
78
+ ),
79
+ ContractVariable(
80
+ key=EXERCISE + ".description",
81
+ label="Description of the exercise",
82
+ type=VariableType.String,
83
+ cardinality=ContractCardinality.One,
84
+ ),
85
+ ],
86
+ )
87
+
88
+ @staticmethod
89
+ def team_variable():
90
+ return ContractVariable(
91
+ key=TEAMS,
92
+ label="List of team name for the injection",
93
+ type=VariableType.String,
94
+ cardinality=ContractCardinality.Multiple,
95
+ )
96
+
97
+ @staticmethod
98
+ def uri_variables():
99
+ return [
100
+ ContractVariable(
101
+ key=PLAYER_URI,
102
+ label="Player interface platform link",
103
+ type=VariableType.String,
104
+ cardinality=ContractCardinality.One,
105
+ ),
106
+ ContractVariable(
107
+ key=CHALLENGES_URI,
108
+ label="Challenges interface platform link",
109
+ type=VariableType.String,
110
+ cardinality=ContractCardinality.One,
111
+ ),
112
+ ContractVariable(
113
+ key=SCOREBOARD_URI,
114
+ label="Scoreboard interface platform link",
115
+ type=VariableType.String,
116
+ cardinality=ContractCardinality.One,
117
+ ),
118
+ ContractVariable(
119
+ key=LESSONS_URI,
120
+ label="Lessons learned interface platform link",
121
+ type=VariableType.String,
122
+ cardinality=ContractCardinality.One,
123
+ ),
124
+ ]
@@ -0,0 +1,4 @@
1
+ from .base_daemon import BaseDaemon
2
+ from .collector_daemon import CollectorDaemon
3
+
4
+ __all__ = ["BaseDaemon", "CollectorDaemon"]
@@ -0,0 +1,131 @@
1
+ from abc import ABC, abstractmethod
2
+ from inspect import signature
3
+ from types import FunctionType
4
+
5
+ from pyoaev.client import OpenAEV
6
+ from pyoaev.configuration import Configuration
7
+ from pyoaev.exceptions import OpenAEVError
8
+ from pyoaev.utils import logger
9
+
10
+
11
+ class BaseDaemon(ABC):
12
+ """A base class for implementing a kind of daemon that periodically polls
13
+ a given callback.
14
+
15
+ :param configuration: configuration to provide the daemon
16
+ (allowing for looking up values within the callback for example)
17
+ :type configuration: Configuration
18
+ :param callback: a method or function to periodically call, defaults to None.
19
+ :type callback: callable, optional
20
+ :param logger: a logger object, to log events. if not supplied, a default logger
21
+ will be spawned to provide this functionality.
22
+ :type logger: Any
23
+ :param api_client: an API client that will provide connectivity with other systems.
24
+ :type api_client: Any
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ configuration: Configuration,
30
+ callback: callable = None,
31
+ logger=None,
32
+ api_client=None,
33
+ ):
34
+ self._configuration = configuration
35
+ self._callback = callback
36
+ self.api = api_client or BaseDaemon.__get_default_api_client(
37
+ url=self._configuration.get("openaev_url"),
38
+ token=self._configuration.get("openaev_token"),
39
+ )
40
+
41
+ # logging
42
+ # compatibility layer: in order for older configs to still work, search for legacy names
43
+ actual_log_level = (
44
+ self._configuration.get("log_level")
45
+ or self._configuration.get("collector_log_level")
46
+ or self._configuration.get("injector_log_level")
47
+ or "info"
48
+ )
49
+ actual_log_name = (
50
+ self._configuration.get("name")
51
+ or self._configuration.get("collector_name")
52
+ or self._configuration.get("injector_name")
53
+ or "daemon"
54
+ )
55
+ self.logger = logger or BaseDaemon.__get_default_logger(
56
+ actual_log_level.upper(),
57
+ actual_log_name,
58
+ )
59
+
60
+ @abstractmethod
61
+ def _setup(self):
62
+ """A run-once method that inheritors must implement. This serves to instantiate
63
+ all useful objects and functionality for the implementor to run.
64
+ """
65
+ pass
66
+
67
+ @abstractmethod
68
+ def _start_loop(self):
69
+ """Starts the daemon's main execution loop. Implementors should implement
70
+ the main execution logic in here.
71
+ """
72
+ pass
73
+
74
+ def _try_callback(self):
75
+ """Tries to call the configured callback. Note that if any error is thrown,
76
+ it is immediately swallowed (but still logged) allowing the collector to keep
77
+ running. This is useful for any transient issue (e.g. API endpoint down...).
78
+ """
79
+ try:
80
+ # this is some black magic to allow injecting the collector daemon instance
81
+ # into an arbitrary callback that has a specific argument name
82
+ # this allow for avoiding subclassing the CollectorDaemon class just to provide the callback
83
+ # Example:
84
+ #
85
+ # def standalone_func(collector):
86
+ # collector.api.call_openaev()
87
+ #
88
+ # CollectorDaemon(config=<pyboas.configuration.Configuration>, standalone_func).start()
89
+ if (
90
+ isinstance(self._callback, FunctionType)
91
+ and "collector" in signature(self._callback).parameters
92
+ ):
93
+ self._callback(collector=self)
94
+ else:
95
+ self._callback()
96
+ except Exception as err: # pylint: disable=broad-except
97
+ self.logger.error(f"Error calling: {err}")
98
+
99
+ def start(self):
100
+ """Start the daemon. This will run the implementor's run-once setup method and
101
+ follow-up with the main execution loop. Note that at this point, if there is no
102
+ configured callback, the method will abort and kill the daemon.
103
+ """
104
+ if self._callback is None:
105
+ raise OpenAEVError("This daemon has no configured callback.")
106
+ self._setup()
107
+ self._start_loop()
108
+
109
+ def set_callback(self, callback: callable):
110
+ """Configures a callback to call in the main execution loop. If the callback
111
+ was not provided in the daemon's ctor, this should be set before calling start().
112
+ """
113
+ self._callback = callback
114
+
115
+ def get_id(self):
116
+ """Returns the daemon instance's ID contained in configuration. Configuration
117
+ must define any of these keys: `id`, `collector_id`, `injector_id`.
118
+ """
119
+ return (
120
+ self._configuration.get("id")
121
+ or self._configuration.get("collector_id")
122
+ or self._configuration.get("injector_id")
123
+ )
124
+
125
+ @classmethod
126
+ def __get_default_api_client(cls, url, token):
127
+ return OpenAEV(url=url, token=token)
128
+
129
+ @classmethod
130
+ def __get_default_logger(cls, log_level, name):
131
+ return logger(log_level)(name)
@@ -0,0 +1,91 @@
1
+ import sched
2
+ import time
3
+
4
+ from pyoaev import Configuration
5
+ from pyoaev.daemons import BaseDaemon
6
+ from pyoaev.utils import PingAlive
7
+
8
+ DEFAULT_PERIOD_SECONDS = 60
9
+
10
+
11
+ class CollectorDaemon(BaseDaemon):
12
+ """Implementation of a daemon of Collector type. Note that it requires
13
+ specific configuration keys to run its setup.
14
+ `collector_icon_filepath`: relative path to an icon image (preferably PNG)
15
+ `collector_id`: unique identifier for the collector (UUIDv4)
16
+ `collector_period`: time to wait in seconds between each loop execution; note
17
+ that this time is added to the time the loop takes to run, so the actual total
18
+ time between each loop start is time_of_loop+period.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ configuration: Configuration,
24
+ callback: callable = None,
25
+ logger=None,
26
+ api_client=None,
27
+ collector_type=None,
28
+ ):
29
+ super().__init__(configuration, callback, logger, api_client)
30
+ if collector_type is None:
31
+ raise ValueError("Must define a value for collector type")
32
+ self.collector_type = collector_type
33
+
34
+ def _setup(self):
35
+ if self._configuration.get("collector_period") is None:
36
+ self._configuration.set("collector_period", DEFAULT_PERIOD_SECONDS)
37
+ icon_path = self._configuration.get("collector_icon_filepath")
38
+ icon_name = self._configuration.get("collector_id") + ".png"
39
+ with open(icon_path, "rb") as icon_file_handle:
40
+ collector_icon = (icon_name, icon_file_handle, "image/png")
41
+ document = self.api.document.upsert(document={}, file=collector_icon)
42
+ if self._configuration.get("collector_platform") is not None:
43
+ security_platform = self.api.security_platform.upsert(
44
+ {
45
+ "asset_name": self._configuration.get("collector_name"),
46
+ "asset_external_reference": self._configuration.get(
47
+ "collector_id"
48
+ ),
49
+ "security_platform_type": self._configuration.get(
50
+ "collector_platform"
51
+ ),
52
+ "security_platform_logo_light": document.get("document_id"),
53
+ "security_platform_logo_dark": document.get("document_id"),
54
+ }
55
+ )
56
+ else:
57
+ security_platform = {}
58
+ security_platform_id = security_platform.get("asset_id")
59
+ config = {
60
+ "collector_id": self._configuration.get("collector_id"),
61
+ "collector_name": self._configuration.get("collector_name"),
62
+ "collector_type": self.collector_type,
63
+ "collector_period": self._configuration.get("collector_period"),
64
+ "collector_security_platform": security_platform_id,
65
+ }
66
+ with open(icon_path, "rb") as icon_file_handle:
67
+ collector_icon = (icon_name, icon_file_handle, "image/png")
68
+ self.api.collector.create(config, collector_icon)
69
+
70
+ PingAlive(self.api, config, self.logger, "collector").start()
71
+
72
+ def _start_loop(self):
73
+ scheduler = sched.scheduler(time.time, time.sleep)
74
+ delay = self._configuration.get("collector_period")
75
+ self._try_callback()
76
+ scheduler.enter(
77
+ delay=delay,
78
+ priority=1,
79
+ action=self.__schedule,
80
+ argument=(scheduler, self._try_callback, delay),
81
+ )
82
+ scheduler.run()
83
+
84
+ def __schedule(self, scheduler, callback, delay):
85
+ callback()
86
+ scheduler.enter(
87
+ delay=delay,
88
+ priority=1,
89
+ action=self.__schedule,
90
+ argument=(scheduler, callback, delay),
91
+ )
pyoaev/exceptions.py ADDED
@@ -0,0 +1,219 @@
1
+ import functools
2
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar, Union, cast
3
+
4
+
5
+ class OpenAEVError(Exception):
6
+ def __init__(
7
+ self,
8
+ error_message: Union[str, bytes] = "",
9
+ response_code: Optional[int] = None,
10
+ response_body: Optional[bytes] = None,
11
+ ) -> None:
12
+ Exception.__init__(self, error_message)
13
+ # Http status code
14
+ self.response_code = response_code
15
+ # Full http response
16
+ self.response_body = response_body
17
+ try:
18
+ # if we receive str/bytes we try to convert to unicode/str to have
19
+ # consistent message types (see #616)
20
+ if TYPE_CHECKING:
21
+ assert isinstance(error_message, bytes)
22
+ self.error_message = error_message.decode()
23
+ except Exception:
24
+ if TYPE_CHECKING:
25
+ assert isinstance(error_message, str)
26
+ self.error_message = error_message
27
+
28
+ def __str__(self) -> str:
29
+ # Start with the provided error message
30
+ message = self.error_message
31
+
32
+ # List of generic HTTP status messages that indicate we should look deeper
33
+ generic_messages = (
34
+ "Internal Server Error",
35
+ "Bad Request",
36
+ "Not Found",
37
+ "Unauthorized",
38
+ "Forbidden",
39
+ "Service Unavailable",
40
+ "Gateway Timeout",
41
+ "Unknown error",
42
+ "Validation Failed",
43
+ )
44
+
45
+ # Only try to extract from response body if message is truly generic
46
+ # Don't override if we already have a specific error message
47
+ if (
48
+ not message or (message in generic_messages and len(message) < 30)
49
+ ) and self.response_body is not None:
50
+ try:
51
+ import json
52
+
53
+ body = self.response_body.decode(errors="ignore")
54
+ data = json.loads(body)
55
+ extracted_msg = None
56
+
57
+ if isinstance(data, dict):
58
+ # Try various common error fields
59
+ if "error" in data:
60
+ err = data.get("error")
61
+ if isinstance(err, dict) and "message" in err:
62
+ extracted_msg = err.get("message")
63
+ elif isinstance(err, str):
64
+ extracted_msg = err
65
+ elif "message" in data:
66
+ extracted_msg = data.get("message")
67
+ elif "execution_message" in data:
68
+ extracted_msg = data.get("execution_message")
69
+ elif "detail" in data:
70
+ extracted_msg = data.get("detail")
71
+ elif "errors" in data:
72
+ errs = data.get("errors")
73
+ if isinstance(errs, list) and errs:
74
+ # Join any messages in the list
75
+ parts = []
76
+ for item in errs:
77
+ if isinstance(item, dict) and "message" in item:
78
+ parts.append(str(item.get("message")))
79
+ else:
80
+ parts.append(str(item))
81
+ extracted_msg = "; ".join(parts)
82
+ elif isinstance(errs, dict):
83
+ # Handle nested validation errors structure
84
+ if "children" in errs:
85
+ validation_errors = []
86
+ children = errs.get("children", {})
87
+ for field, field_errors in children.items():
88
+ if (
89
+ isinstance(field_errors, dict)
90
+ and "errors" in field_errors
91
+ ):
92
+ field_error_list = field_errors.get(
93
+ "errors", []
94
+ )
95
+ if field_error_list:
96
+ for err_msg in field_error_list:
97
+ validation_errors.append(
98
+ f"{field}: {err_msg}"
99
+ )
100
+ if validation_errors:
101
+ base_msg = data.get("message", "Validation Failed")
102
+ extracted_msg = (
103
+ f"{base_msg}: {'; '.join(validation_errors)}"
104
+ )
105
+ else:
106
+ # Try to get any string representation
107
+ parts = []
108
+ for key, value in errs.items():
109
+ if value:
110
+ parts.append(f"{key}: {value}")
111
+ if parts:
112
+ extracted_msg = "; ".join(parts)
113
+ elif isinstance(errs, str):
114
+ extracted_msg = errs
115
+
116
+ # Use extracted message if it's better than what we have
117
+ if extracted_msg and extracted_msg not in generic_messages:
118
+ message = str(extracted_msg)
119
+ elif not message:
120
+ # Last resort: use the raw body
121
+ message = body[:500]
122
+
123
+ except json.JSONDecodeError:
124
+ # Not JSON, use raw text if we don't have a good message
125
+ if not message or message in generic_messages:
126
+ try:
127
+ decoded = self.response_body.decode(errors="ignore")[:500]
128
+ if decoded and decoded not in generic_messages:
129
+ message = decoded
130
+ except Exception:
131
+ pass
132
+ except Exception:
133
+ pass
134
+
135
+ # Final fallback
136
+ if not message:
137
+ message = "Unknown error"
138
+
139
+ # Clean up the message - remove extra whitespace and newlines
140
+ message = " ".join(message.split())
141
+
142
+ if self.response_code is not None:
143
+ return f"{self.response_code}: {message}"
144
+ return f"{message}"
145
+
146
+
147
+ class OpenAEVAuthenticationError(OpenAEVError):
148
+ pass
149
+
150
+
151
+ class OpenAEVHttpError(OpenAEVError):
152
+ pass
153
+
154
+
155
+ class OpenAEVParsingError(OpenAEVError):
156
+ pass
157
+
158
+
159
+ class RedirectError(OpenAEVError):
160
+ pass
161
+
162
+
163
+ class OpenAEVHeadError(OpenAEVError):
164
+ pass
165
+
166
+
167
+ class OpenAEVGetError(OpenAEVError):
168
+ pass
169
+
170
+
171
+ class OpenAEVUpdateError(OpenAEVError):
172
+ pass
173
+
174
+
175
+ class OpenAEVListError(OpenAEVError):
176
+ pass
177
+
178
+
179
+ class OpenAEVCreateError(OpenAEVError):
180
+ pass
181
+
182
+
183
+ class ConfigurationError(OpenAEVError):
184
+ pass
185
+
186
+
187
+ # For an explanation of how these type-hints work see:
188
+ # https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
189
+ #
190
+ # The goal here is that functions which get decorated will retain their types.
191
+ __F = TypeVar("__F", bound=Callable[..., Any])
192
+
193
+
194
+ def on_http_error(error: Type[Exception]) -> Callable[[__F], __F]:
195
+ def wrap(f: __F) -> __F:
196
+ @functools.wraps(f)
197
+ def wrapped_f(*args: Any, **kwargs: Any) -> Any:
198
+ try:
199
+ return f(*args, **kwargs)
200
+ except OpenAEVHttpError as e:
201
+ raise error(e.error_message, e.response_code, e.response_body) from e
202
+
203
+ return cast(__F, wrapped_f)
204
+
205
+ return wrap
206
+
207
+
208
+ # Export manually to keep mypy happy
209
+ __all__ = [
210
+ "ConfigurationError",
211
+ "OpenAEVAuthenticationError",
212
+ "OpenAEVHttpError",
213
+ "OpenAEVParsingError",
214
+ "RedirectError",
215
+ "OpenAEVHeadError",
216
+ "OpenAEVListError",
217
+ "OpenAEVGetError",
218
+ "OpenAEVUpdateError",
219
+ ]