howler-api 2.13.0.dev329__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.

Potentially problematic release.


This version of howler-api might be problematic. Click here for more details.

Files changed (200) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +167 -0
  3. howler/actions/add_label.py +111 -0
  4. howler/actions/add_to_bundle.py +159 -0
  5. howler/actions/change_field.py +76 -0
  6. howler/actions/demote.py +160 -0
  7. howler/actions/example_plugin.py +104 -0
  8. howler/actions/prioritization.py +93 -0
  9. howler/actions/promote.py +147 -0
  10. howler/actions/remove_from_bundle.py +133 -0
  11. howler/actions/remove_label.py +111 -0
  12. howler/actions/transition.py +200 -0
  13. howler/api/__init__.py +249 -0
  14. howler/api/base.py +88 -0
  15. howler/api/socket.py +114 -0
  16. howler/api/v1/__init__.py +97 -0
  17. howler/api/v1/action.py +372 -0
  18. howler/api/v1/analytic.py +748 -0
  19. howler/api/v1/auth.py +382 -0
  20. howler/api/v1/borealis.py +101 -0
  21. howler/api/v1/configs.py +55 -0
  22. howler/api/v1/dossier.py +222 -0
  23. howler/api/v1/help.py +28 -0
  24. howler/api/v1/hit.py +1181 -0
  25. howler/api/v1/notebook.py +82 -0
  26. howler/api/v1/overview.py +191 -0
  27. howler/api/v1/search.py +715 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +414 -0
  31. howler/api/v1/utils/__init__.py +0 -0
  32. howler/api/v1/utils/etag.py +84 -0
  33. howler/api/v1/view.py +288 -0
  34. howler/app.py +235 -0
  35. howler/common/README.md +144 -0
  36. howler/common/__init__.py +0 -0
  37. howler/common/classification.py +979 -0
  38. howler/common/classification.yml +107 -0
  39. howler/common/exceptions.py +167 -0
  40. howler/common/hexdump.py +48 -0
  41. howler/common/iprange.py +171 -0
  42. howler/common/loader.py +154 -0
  43. howler/common/logging/__init__.py +241 -0
  44. howler/common/logging/audit.py +138 -0
  45. howler/common/logging/format.py +38 -0
  46. howler/common/net.py +79 -0
  47. howler/common/net_static.py +1494 -0
  48. howler/common/random_user.py +316 -0
  49. howler/common/swagger.py +117 -0
  50. howler/config.py +64 -0
  51. howler/cronjobs/__init__.py +29 -0
  52. howler/cronjobs/retention.py +61 -0
  53. howler/cronjobs/rules.py +274 -0
  54. howler/cronjobs/view_cleanup.py +88 -0
  55. howler/datastore/README.md +112 -0
  56. howler/datastore/__init__.py +0 -0
  57. howler/datastore/bulk.py +72 -0
  58. howler/datastore/collection.py +2327 -0
  59. howler/datastore/constants.py +117 -0
  60. howler/datastore/exceptions.py +41 -0
  61. howler/datastore/howler_store.py +105 -0
  62. howler/datastore/migrations/fix_process.py +41 -0
  63. howler/datastore/operations.py +130 -0
  64. howler/datastore/schemas.py +90 -0
  65. howler/datastore/store.py +231 -0
  66. howler/datastore/support/__init__.py +0 -0
  67. howler/datastore/support/build.py +214 -0
  68. howler/datastore/support/schemas.py +90 -0
  69. howler/datastore/types.py +22 -0
  70. howler/error.py +91 -0
  71. howler/external/__init__.py +0 -0
  72. howler/external/generate_mitre.py +96 -0
  73. howler/external/generate_sigma_rules.py +31 -0
  74. howler/external/generate_tlds.py +47 -0
  75. howler/external/reindex_data.py +46 -0
  76. howler/external/wipe_databases.py +58 -0
  77. howler/gunicorn_config.py +25 -0
  78. howler/healthz.py +47 -0
  79. howler/helper/__init__.py +0 -0
  80. howler/helper/azure.py +50 -0
  81. howler/helper/discover.py +59 -0
  82. howler/helper/hit.py +236 -0
  83. howler/helper/oauth.py +247 -0
  84. howler/helper/search.py +92 -0
  85. howler/helper/workflow.py +110 -0
  86. howler/helper/ws.py +378 -0
  87. howler/odm/README.md +102 -0
  88. howler/odm/__init__.py +1 -0
  89. howler/odm/base.py +1504 -0
  90. howler/odm/charter.txt +146 -0
  91. howler/odm/helper.py +416 -0
  92. howler/odm/howler_enum.py +25 -0
  93. howler/odm/models/__init__.py +0 -0
  94. howler/odm/models/action.py +33 -0
  95. howler/odm/models/analytic.py +90 -0
  96. howler/odm/models/assemblyline.py +48 -0
  97. howler/odm/models/aws.py +23 -0
  98. howler/odm/models/azure.py +16 -0
  99. howler/odm/models/cbs.py +44 -0
  100. howler/odm/models/config.py +558 -0
  101. howler/odm/models/dossier.py +33 -0
  102. howler/odm/models/ecs/__init__.py +0 -0
  103. howler/odm/models/ecs/agent.py +17 -0
  104. howler/odm/models/ecs/autonomous_system.py +16 -0
  105. howler/odm/models/ecs/client.py +149 -0
  106. howler/odm/models/ecs/cloud.py +141 -0
  107. howler/odm/models/ecs/code_signature.py +27 -0
  108. howler/odm/models/ecs/container.py +32 -0
  109. howler/odm/models/ecs/dns.py +62 -0
  110. howler/odm/models/ecs/egress.py +10 -0
  111. howler/odm/models/ecs/elf.py +74 -0
  112. howler/odm/models/ecs/email.py +122 -0
  113. howler/odm/models/ecs/error.py +14 -0
  114. howler/odm/models/ecs/event.py +140 -0
  115. howler/odm/models/ecs/faas.py +24 -0
  116. howler/odm/models/ecs/file.py +84 -0
  117. howler/odm/models/ecs/geo.py +30 -0
  118. howler/odm/models/ecs/group.py +18 -0
  119. howler/odm/models/ecs/hash.py +16 -0
  120. howler/odm/models/ecs/host.py +17 -0
  121. howler/odm/models/ecs/http.py +37 -0
  122. howler/odm/models/ecs/ingress.py +12 -0
  123. howler/odm/models/ecs/interface.py +21 -0
  124. howler/odm/models/ecs/network.py +30 -0
  125. howler/odm/models/ecs/observer.py +45 -0
  126. howler/odm/models/ecs/organization.py +12 -0
  127. howler/odm/models/ecs/os.py +21 -0
  128. howler/odm/models/ecs/pe.py +17 -0
  129. howler/odm/models/ecs/process.py +216 -0
  130. howler/odm/models/ecs/registry.py +26 -0
  131. howler/odm/models/ecs/related.py +45 -0
  132. howler/odm/models/ecs/rule.py +51 -0
  133. howler/odm/models/ecs/server.py +24 -0
  134. howler/odm/models/ecs/threat.py +247 -0
  135. howler/odm/models/ecs/tls.py +58 -0
  136. howler/odm/models/ecs/url.py +51 -0
  137. howler/odm/models/ecs/user.py +57 -0
  138. howler/odm/models/ecs/user_agent.py +20 -0
  139. howler/odm/models/ecs/vulnerability.py +41 -0
  140. howler/odm/models/gcp.py +16 -0
  141. howler/odm/models/hit.py +356 -0
  142. howler/odm/models/howler_data.py +328 -0
  143. howler/odm/models/lead.py +33 -0
  144. howler/odm/models/localized_label.py +13 -0
  145. howler/odm/models/overview.py +16 -0
  146. howler/odm/models/pivot.py +40 -0
  147. howler/odm/models/template.py +24 -0
  148. howler/odm/models/user.py +83 -0
  149. howler/odm/models/view.py +34 -0
  150. howler/odm/random_data.py +888 -0
  151. howler/odm/randomizer.py +606 -0
  152. howler/patched.py +5 -0
  153. howler/plugins/__init__.py +25 -0
  154. howler/plugins/config.py +123 -0
  155. howler/remote/__init__.py +0 -0
  156. howler/remote/datatypes/README.md +355 -0
  157. howler/remote/datatypes/__init__.py +98 -0
  158. howler/remote/datatypes/counters.py +63 -0
  159. howler/remote/datatypes/events.py +66 -0
  160. howler/remote/datatypes/hash.py +206 -0
  161. howler/remote/datatypes/lock.py +42 -0
  162. howler/remote/datatypes/queues/__init__.py +0 -0
  163. howler/remote/datatypes/queues/comms.py +59 -0
  164. howler/remote/datatypes/queues/multi.py +32 -0
  165. howler/remote/datatypes/queues/named.py +93 -0
  166. howler/remote/datatypes/queues/priority.py +215 -0
  167. howler/remote/datatypes/set.py +118 -0
  168. howler/remote/datatypes/user_quota_tracker.py +54 -0
  169. howler/security/__init__.py +253 -0
  170. howler/security/socket.py +108 -0
  171. howler/security/utils.py +185 -0
  172. howler/services/__init__.py +0 -0
  173. howler/services/action_service.py +111 -0
  174. howler/services/analytic_service.py +128 -0
  175. howler/services/auth_service.py +323 -0
  176. howler/services/config_service.py +128 -0
  177. howler/services/dossier_service.py +252 -0
  178. howler/services/event_service.py +93 -0
  179. howler/services/hit_service.py +893 -0
  180. howler/services/jwt_service.py +158 -0
  181. howler/services/lucene_service.py +286 -0
  182. howler/services/notebook_service.py +119 -0
  183. howler/services/overview_service.py +44 -0
  184. howler/services/template_service.py +45 -0
  185. howler/services/user_service.py +330 -0
  186. howler/utils/__init__.py +0 -0
  187. howler/utils/annotations.py +28 -0
  188. howler/utils/chunk.py +38 -0
  189. howler/utils/dict_utils.py +200 -0
  190. howler/utils/isotime.py +17 -0
  191. howler/utils/list_utils.py +11 -0
  192. howler/utils/lucene.py +77 -0
  193. howler/utils/path.py +27 -0
  194. howler/utils/socket_utils.py +61 -0
  195. howler/utils/str_utils.py +256 -0
  196. howler/utils/uid.py +47 -0
  197. howler_api-2.13.0.dev329.dist-info/METADATA +71 -0
  198. howler_api-2.13.0.dev329.dist-info/RECORD +200 -0
  199. howler_api-2.13.0.dev329.dist-info/WHEEL +4 -0
  200. howler_api-2.13.0.dev329.dist-info/entry_points.txt +8 -0
@@ -0,0 +1,200 @@
1
+ import inspect
2
+ from typing import Optional
3
+
4
+ from howler.common.exceptions import InvalidDataException, NotFoundException
5
+ from howler.common.loader import datastore
6
+ from howler.common.logging import get_logger
7
+ from howler.helper.workflow import Workflow, WorkflowException
8
+ from howler.odm.models.action import VALID_TRIGGERS
9
+ from howler.odm.models.howler_data import (
10
+ Assessment,
11
+ HitStatus,
12
+ HitStatusTransition,
13
+ Vote,
14
+ )
15
+ from howler.odm.models.user import User
16
+ from howler.services import event_service, hit_service
17
+ from howler.utils.list_utils import flatten_list
18
+
19
+ OPERATION_ID = "transition"
20
+
21
+ log = get_logger(__file__)
22
+
23
+
24
+ def __parse_workflow_actions(workflow: Workflow) -> dict[str, set[str]]:
25
+ """Take in a workflow, and parse the steps and transitions of that workflow into a format understood by the UI"""
26
+ parsed_args: dict[str, set[str]] = {}
27
+
28
+ for wf in workflow.transitions.values():
29
+ if wf["transition"] in [
30
+ HitStatusTransition.RE_EVALUATE,
31
+ HitStatusTransition.PROMOTE,
32
+ HitStatusTransition.DEMOTE,
33
+ ]:
34
+ continue
35
+
36
+ wf_args = flatten_list(
37
+ [
38
+ [var for var in inspect.getfullargspec(m)[0] if var not in ["kwargs", "hit", "user", "transition"]]
39
+ for m in wf["actions"]
40
+ ]
41
+ )
42
+
43
+ for key in wf_args:
44
+ entry = f'transition:{str(wf["transition"])}'
45
+
46
+ if key in parsed_args:
47
+ parsed_args[key].add(entry)
48
+ else:
49
+ parsed_args[key] = {entry}
50
+
51
+ return parsed_args
52
+
53
+
54
+ def execute(
55
+ query: str,
56
+ status: str,
57
+ transition: str,
58
+ user: User,
59
+ request_id: Optional[str] = None,
60
+ **kwargs,
61
+ ):
62
+ """Attempt to excute a transition on a hit.
63
+
64
+ The hit must be in the specified status in order for the action to execute - otherwise, the automation will filter
65
+ out those options.
66
+
67
+ Args:
68
+ query (str): The query on which to apply this automation.
69
+ request_id (str): The id of this automation run. Used to track the progress via websockets.
70
+ status (str): The status from which to transition.
71
+ transition (str): The transition to attempt to execute.
72
+ """
73
+ rows = 1000 if "automation_advanced" in user.type else 10
74
+ hits = datastore().hit.search(f"({query}) AND howler.status:{status}", rows=rows, fl="howler.id")
75
+
76
+ ids = [hit.howler.id for hit in hits["items"]]
77
+
78
+ if len(ids) < 1:
79
+ return [
80
+ {
81
+ "query": query,
82
+ "outcome": "skipped",
83
+ "title": "No matching hits",
84
+ "message": "No hits matched this query, so the automation skipped.",
85
+ }
86
+ ]
87
+
88
+ report = []
89
+
90
+ if rows < hits["total"]:
91
+ report.append(
92
+ {
93
+ "query": query,
94
+ "outcome": "skipped",
95
+ "title": "Too Many Hits",
96
+ "message": f"A maximum of {rows} hits can be processed at once, but {hits['total']} matched the query.",
97
+ }
98
+ )
99
+
100
+ num_skipped = datastore().hit.search(f"({query}) AND -howler.status:{status}", rows=1)["total"]
101
+
102
+ if num_skipped > 0:
103
+ report.append(
104
+ {
105
+ "query": f"({query}) AND -howler.status:{status}",
106
+ "outcome": "skipped",
107
+ "title": f"Skipped {num_skipped} hits",
108
+ "message": f"These hits did not have the correct status ({status}), and were skipped.",
109
+ }
110
+ )
111
+
112
+ success_ids = set()
113
+ total_processed = 0
114
+ for hit_id in ids:
115
+ try:
116
+ hit_service.transition_hit(
117
+ hit_id,
118
+ HitStatusTransition[transition],
119
+ user,
120
+ **kwargs,
121
+ )
122
+ success_ids.add(hit_id)
123
+ except (InvalidDataException, NotFoundException, WorkflowException) as e:
124
+ report.append(
125
+ {
126
+ "query": f"howler.id:{hit_id}",
127
+ "outcome": "error",
128
+ "title": "An error occurred while processing.",
129
+ "message": str(e),
130
+ }
131
+ )
132
+
133
+ total_processed += 1
134
+ if total_processed % 10 == 0:
135
+ log.debug("Transition executed on %s hits", total_processed)
136
+ if request_id is not None:
137
+ event_service.emit(
138
+ "automation",
139
+ {
140
+ "request_id": request_id,
141
+ "processed": total_processed,
142
+ "total": len(ids),
143
+ },
144
+ )
145
+
146
+ log.info(
147
+ "Transition %s processed on %s hits (%s successful)",
148
+ transition,
149
+ len(ids),
150
+ len(success_ids),
151
+ )
152
+
153
+ if len(success_ids) > 0:
154
+ report.append(
155
+ {
156
+ "query": f"howler.id:({' OR '.join(success_ids)})",
157
+ "outcome": "success",
158
+ "title": "Transition Executed Successfully",
159
+ "message": f"The transition {transition} successfully executed on {len(success_ids)} hits.",
160
+ }
161
+ )
162
+
163
+ datastore().hit.commit()
164
+
165
+ return report
166
+
167
+
168
+ def specification():
169
+ """Specify various properties of the action, such as title, descriptions, permissions and input steps."""
170
+ return {
171
+ "id": OPERATION_ID,
172
+ "title": "Transition",
173
+ "priority": 9,
174
+ "i18nKey": "operations.transition",
175
+ "description": {
176
+ "short": "Transition a hit",
177
+ "long": execute.__doc__,
178
+ },
179
+ "roles": ["automation_basic"],
180
+ "steps": [
181
+ {
182
+ "args": {"status": []},
183
+ "options": {"status": HitStatus.list()},
184
+ "validation": {"error": {"query": "-howler.status:$status"}},
185
+ },
186
+ {
187
+ "args": {"transition": []},
188
+ "options": {
189
+ "transition": {
190
+ f"status:{status}": hit_service.get_transitions(status) for status in HitStatus.list()
191
+ },
192
+ },
193
+ },
194
+ {
195
+ "args": __parse_workflow_actions(hit_service.get_hit_workflow()),
196
+ "options": {"vote": Vote.list(), "assessment": Assessment.list()},
197
+ },
198
+ ],
199
+ "triggers": [trigger for trigger in VALID_TRIGGERS if trigger != "create"],
200
+ }
howler/api/__init__.py ADDED
@@ -0,0 +1,249 @@
1
+ from sys import exc_info
2
+ from traceback import format_tb
3
+ from typing import Any, Union
4
+
5
+ from flask import Blueprint, Response, jsonify, make_response, request
6
+ from flask import session as flsk_session
7
+ from prometheus_client import Counter
8
+
9
+ from howler import odm
10
+ from howler.common.loader import APP_NAME
11
+ from howler.common.logging import get_logger, log_with_traceback
12
+ from howler.config import QUOTA_TRACKER, get_version
13
+ from howler.utils.str_utils import safe_str
14
+
15
+ API_PREFIX = "/api"
16
+ RAW_API_COUNTER = Counter(
17
+ f"{APP_NAME.replace('-', '_')}_http_requests_total",
18
+ "HTTP Requests broken down by method, path, and status",
19
+ ["method", "path", "status"],
20
+ )
21
+
22
+ logger = get_logger(__file__)
23
+
24
+
25
+ def make_subapi_blueprint(name, api_version=1):
26
+ """Create a flask Blueprint for a subapi in a standard way."""
27
+ return Blueprint(name, name, url_prefix="/".join([API_PREFIX, f"v{api_version}", name]))
28
+
29
+
30
+ def _make_api_response(
31
+ data: Any, err: Union[str, Exception] = "", warnings: list[str] = [], status_code: int = 200, cookies: Any = None
32
+ ) -> Response:
33
+ quota_user = flsk_session.pop("quota_user", None)
34
+ quota_set = flsk_session.pop("quota_set", False)
35
+ if quota_user and quota_set and not request.path.startswith("/api/v1/borealis"):
36
+ QUOTA_TRACKER.end(quota_user)
37
+
38
+ if type(err) is Exception: # pragma: no cover
39
+ trace = exc_info()[2]
40
+ err = "".join(["\n"] + format_tb(trace) + ["%s: %s\n" % (err.__class__.__name__, str(err))]).rstrip("\n")
41
+ log_with_traceback(trace, "Exception", is_exception=True)
42
+
43
+ if isinstance(data, odm.Model):
44
+ data = data.as_primitives()
45
+
46
+ if isinstance(data, list) and len(data) > 0 and isinstance(data[0], odm.Model):
47
+ for i in range(len(data)):
48
+ data[i] = data[i].as_primitives()
49
+
50
+ resp = make_response(
51
+ jsonify(
52
+ {
53
+ "api_response": data,
54
+ "api_error_message": err,
55
+ "api_warning": warnings,
56
+ "api_server_version": get_version(),
57
+ "api_status_code": status_code,
58
+ }
59
+ ),
60
+ status_code,
61
+ )
62
+
63
+ if isinstance(cookies, dict):
64
+ for k, v in cookies.items():
65
+ resp.set_cookie(k, v, secure=True, httponly=True, samesite="Lax")
66
+
67
+ RAW_API_COUNTER.labels(request.method, str(request.url_rule), status_code).inc()
68
+ logger.info("%s %s - %s", request.method, request.path, status_code)
69
+
70
+ return resp
71
+
72
+
73
+ # Some helper functions for make_api_response
74
+
75
+ DEFAULT_DATA = {True: {"success": True}, False: {"success": False}}
76
+
77
+
78
+ def ok(data=DEFAULT_DATA[True], cookies=None):
79
+ """Returns response with status code 200"""
80
+ return _make_api_response(data, status_code=200, cookies=cookies)
81
+
82
+
83
+ def created(data=DEFAULT_DATA[True], warnings=[], cookies=None):
84
+ """Returns response with status code 201"""
85
+ return _make_api_response(data, warnings=warnings, status_code=201, cookies=cookies)
86
+
87
+
88
+ def accepted(data=DEFAULT_DATA[True], cookies=None):
89
+ """Returns response with status code 202"""
90
+ return _make_api_response(data, status_code=202, cookies=cookies)
91
+
92
+
93
+ def no_content(data=None, cookies=None):
94
+ """Returns response with status code 204"""
95
+ return _make_api_response(data or DEFAULT_DATA[True], status_code=204, cookies=cookies)
96
+
97
+
98
+ def not_modified(data=DEFAULT_DATA[True], cookies=None):
99
+ """Returns response with status code 304"""
100
+ return _make_api_response(data, status_code=304, cookies=cookies)
101
+
102
+
103
+ def bad_request(data=DEFAULT_DATA[False], err="", cookies=None, warnings=None):
104
+ """Returns response with status code ies"""
105
+ return _make_api_response(data, err, status_code=400, cookies=cookies, warnings=warnings)
106
+
107
+
108
+ def unauthorized(data=DEFAULT_DATA[False], err="", cookies=None):
109
+ """Returns response with status code 401"""
110
+ return _make_api_response(data, err, status_code=401, cookies=cookies)
111
+
112
+
113
+ def forbidden(data=DEFAULT_DATA[False], err="", cookies=None):
114
+ """Returns response with status code 403"""
115
+ return _make_api_response(data, err, status_code=403, cookies=cookies)
116
+
117
+
118
+ def not_found(data=DEFAULT_DATA[False], err="", cookies=None):
119
+ """Returns response with status code 404"""
120
+ return _make_api_response(data, err, status_code=404, cookies=cookies)
121
+
122
+
123
+ def conflict(data=DEFAULT_DATA[False], err="", cookies=None):
124
+ """Returns response with status code 409"""
125
+ return _make_api_response(data, err, status_code=409, cookies=cookies)
126
+
127
+
128
+ def precondition_failed(data=DEFAULT_DATA[False], err="", cookies=None):
129
+ """Returns response with status code 412"""
130
+ return _make_api_response(data, err, status_code=412, cookies=cookies)
131
+
132
+
133
+ def teapot(data={**DEFAULT_DATA[False], "teapot": True}, err="", cookies=None):
134
+ """Returns response with status code 418"""
135
+ return _make_api_response(data, err, status_code=418, cookies=cookies)
136
+
137
+
138
+ def too_many_requests(data=DEFAULT_DATA[False], err="", cookies=None):
139
+ """Returns response with status code 429"""
140
+ return _make_api_response(data, err, status_code=429, cookies=cookies)
141
+
142
+
143
+ def internal_error(
144
+ data={**DEFAULT_DATA[False]},
145
+ err="Something went wrong. Contact an administrator.",
146
+ cookies=None,
147
+ ):
148
+ """Returns response with status code 500"""
149
+ return _make_api_response(data, err, status_code=500, cookies=cookies)
150
+
151
+
152
+ def not_implemented(
153
+ data={**DEFAULT_DATA[False]},
154
+ err="Something went wrong. Contact an administrator.",
155
+ cookies=None,
156
+ ):
157
+ """Returns response with status code 501"""
158
+ return _make_api_response(data, err, status_code=501, cookies=cookies)
159
+
160
+
161
+ def bad_gateway(
162
+ data={**DEFAULT_DATA[False]},
163
+ err="Something went wrong. Contact an administrator.",
164
+ cookies=None,
165
+ ):
166
+ """Returns response with status code 502"""
167
+ return _make_api_response(data, err, status_code=502, cookies=cookies)
168
+
169
+
170
+ def service_unavailable(
171
+ data={**DEFAULT_DATA[False]},
172
+ err="Something went wrong. Contact an administrator.",
173
+ cookies=None,
174
+ ):
175
+ """Returns response with status code 503"""
176
+ return _make_api_response(data, err, status_code=503, cookies=cookies)
177
+
178
+
179
+ def make_file_response(data, name, size, status_code=200, content_type="application/octet-stream"):
180
+ """Returns file response with arbitrary status code"""
181
+ quota_user = flsk_session.pop("quota_user", None)
182
+ quota_set = flsk_session.pop("quota_set", False)
183
+ if quota_user and quota_set:
184
+ QUOTA_TRACKER.end(quota_user)
185
+
186
+ response = make_response(data, status_code)
187
+ response.headers["Content-Type"] = content_type
188
+ response.headers["Content-Length"] = size
189
+ response.headers["Content-Disposition"] = 'attachment; filename="%s"' % safe_str(name)
190
+ return response
191
+
192
+
193
+ def stream_file_response(reader, name, size, status_code=200):
194
+ """Returns stream response with arbitrary status code"""
195
+ quota_user = flsk_session.pop("quota_user", None)
196
+ quota_set = flsk_session.pop("quota_set", False)
197
+ if quota_user and quota_set:
198
+ QUOTA_TRACKER.end(quota_user)
199
+
200
+ chunk_size = 65535
201
+
202
+ def generate():
203
+ reader.seek(0)
204
+ while True:
205
+ data = reader.read(chunk_size)
206
+ if not data:
207
+ break
208
+ yield data
209
+ reader.close()
210
+
211
+ headers = {
212
+ "Content-Type": "application/octet-stream",
213
+ "Content-Length": size,
214
+ "Content-Disposition": 'attachment; filename="%s"' % safe_str(name),
215
+ }
216
+ return Response(generate(), status=status_code, headers=headers)
217
+
218
+
219
+ def make_binary_response(data, size, status_code=200):
220
+ """Returns binary response with arbitrary status code"""
221
+ quota_user = flsk_session.pop("quota_user", None)
222
+ quota_set = flsk_session.pop("quota_set", False)
223
+ if quota_user and quota_set:
224
+ QUOTA_TRACKER.end(quota_user)
225
+
226
+ response = make_response(data, status_code)
227
+ response.headers["Content-Type"] = "application/octet-stream"
228
+ response.headers["Content-Length"] = size
229
+ return response
230
+
231
+
232
+ def stream_binary_response(reader, status_code=200):
233
+ """Returns streamed binary response with arbitrary status code"""
234
+ quota_user = flsk_session.pop("quota_user", None)
235
+ quota_set = flsk_session.pop("quota_set", False)
236
+ if quota_user and quota_set:
237
+ QUOTA_TRACKER.end(quota_user)
238
+
239
+ chunk_size = 4096
240
+
241
+ def generate():
242
+ reader.seek(0)
243
+ while True:
244
+ data = reader.read(chunk_size)
245
+ if not data:
246
+ break
247
+ yield data
248
+
249
+ return Response(generate(), status=status_code, mimetype="application/octet-stream")
howler/api/base.py ADDED
@@ -0,0 +1,88 @@
1
+ from flask import Blueprint, current_app, request
2
+
3
+ from howler.api import API_PREFIX, ok
4
+ from howler.security import api_login
5
+
6
+ api = Blueprint("api", __name__, url_prefix=API_PREFIX)
7
+
8
+
9
+ #####################################
10
+ # API list API (API inception)
11
+ @api.route("/")
12
+ @api_login(audit=False, required_priv=["R", "W"])
13
+ def api_version_list(**_):
14
+ """List all available API versions.
15
+
16
+ Variables:
17
+ None
18
+
19
+ Arguments:
20
+ None
21
+
22
+ Result Example:
23
+ ["v1", "v2", "v3"] #List of API versions available
24
+ """
25
+ api_list = []
26
+ for rule in current_app.url_map.iter_rules():
27
+ if rule.rule.startswith("/api/"):
28
+ version = rule.rule[5:].split("/", 1)[0]
29
+ if version not in api_list and version != "":
30
+ try:
31
+ int(version[1:])
32
+ except ValueError:
33
+ continue
34
+ api_list.append(version)
35
+
36
+ return ok(api_list)
37
+
38
+
39
+ @api.route("/site_map")
40
+ @api_login(required_type=["admin"], audit=False)
41
+ def site_map(**_):
42
+ """Check if all pages have been protected by a login decorator
43
+
44
+ Variables:
45
+ None
46
+
47
+ Arguments:
48
+ unsafe_only => Only show unsafe pages
49
+
50
+ Result Example:
51
+ [ #List of pages dictionary containing...
52
+ {"function": views.default, #Function name
53
+ "url": "/", #Url to page
54
+ "protected": true, #Is function login protected
55
+ "required_type": false, #List of user type allowed to view the page
56
+ "methods": ["GET"]}, #Methods allowed to access the page
57
+ ]
58
+ """
59
+ pages = []
60
+ for rule in current_app.url_map.iter_rules():
61
+ func = current_app.view_functions[rule.endpoint]
62
+ methods = [item for item in (rule.methods or []) if item != "OPTIONS" and item != "HEAD"]
63
+
64
+ protected = func.__dict__.get("protected", False)
65
+ required_type = func.__dict__.get("required_type", ["user"])
66
+ audit = func.__dict__.get("audit", False)
67
+ priv = func.__dict__.get("required_priv", "")
68
+ if "/api/v1/" in rule.rule:
69
+ prefix = "api.v1."
70
+ else:
71
+ prefix = ""
72
+
73
+ if "unsafe_only" in request.args and protected:
74
+ continue
75
+
76
+ pages.append(
77
+ {
78
+ "function": f"{prefix}{rule.endpoint.replace('apiv1.', '')}",
79
+ "url": rule.rule,
80
+ "methods": methods,
81
+ "protected": protected,
82
+ "required_type": required_type,
83
+ "audit": audit,
84
+ "req_priv": priv,
85
+ }
86
+ )
87
+
88
+ return ok(sorted(pages, key=lambda i: i["url"]))
howler/api/socket.py ADDED
@@ -0,0 +1,114 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ from typing import Any
5
+
6
+ from flask import Blueprint, request
7
+
8
+ import howler.services.event_service as event_service
9
+ from howler.api import ok, unauthorized
10
+ from howler.common.logging import get_logger
11
+ from howler.datastore.operations import OdmHelper
12
+ from howler.helper.ws import ConnectionClosed, Server
13
+ from howler.odm.models.hit import Hit
14
+ from howler.security.socket import websocket_auth, ws_response
15
+ from howler.utils.socket_utils import check_action
16
+
17
+ HWL_INTERPOD_COMMS_SECRET = os.getenv("HWL_INTERPOD_COMMS_SECRET", "secret")
18
+
19
+ socket_api = Blueprint("socket", "socket", url_prefix="/socket/v1")
20
+
21
+ socket_api._doc = "Endpoints concerning websocket connectivity between the client and server" # type: ignore
22
+
23
+ logger = get_logger(__file__)
24
+
25
+ hit_helper = OdmHelper(Hit)
26
+
27
+
28
+ @socket_api.route("/emit/<event>", methods=["POST"])
29
+ def emit(event: str):
30
+ """Emit an event to all listening websockets"""
31
+ if "Authorization" not in request.headers:
32
+ return unauthorized(err="Missing authorization header")
33
+
34
+ auth_data = base64.b64decode(request.headers["Authorization"].split(" ")[1]).decode().split(":")[1]
35
+
36
+ if HWL_INTERPOD_COMMS_SECRET == "secret": # noqa: S105
37
+ logger.warning("Using default interpod secret! DO NOT allow this on a production instance.")
38
+
39
+ if auth_data != HWL_INTERPOD_COMMS_SECRET:
40
+ logger.warning("Invalid auth secret provided: %s (expected: %s)", auth_data, HWL_INTERPOD_COMMS_SECRET)
41
+
42
+ return unauthorized(err="Invalid auth data")
43
+
44
+ event_service.emit(event, request.json)
45
+
46
+ return ok()
47
+
48
+
49
+ @socket_api.route("/connect", websocket=True)
50
+ @websocket_auth(required_priv=["R"])
51
+ def connect(ws: Server, *args: Any, ws_id: str, **kwargs):
52
+ """Connect to the server to monitor for updates via websocket
53
+
54
+ Variables:
55
+ None
56
+
57
+ Optional Arguments:
58
+ None
59
+
60
+ Result Example:
61
+ A continuous websocket connection
62
+ """
63
+ outstanding_actions: list[tuple[str, str, bool]] = []
64
+
65
+ def send_hit(data: dict[str, Any]):
66
+ logger.debug("Sending hit update: %s", data["hit"]["howler"]["id"])
67
+ ws.send(ws_response("hits", data))
68
+
69
+ def send_broadcast(data: dict[str, str]):
70
+ logger.debug("Sending broadcast: %s", data)
71
+ ws.send(ws_response("broadcast", {"event": data}))
72
+
73
+ def send_action(data: dict[str, str]):
74
+ logger.debug("Sending action: %s", data)
75
+ ws.send(ws_response("action", data))
76
+
77
+ try:
78
+ event_service.on("hits", send_hit)
79
+ event_service.on("broadcast", send_broadcast)
80
+ event_service.on("action", send_action)
81
+ while ws.connected:
82
+ data = ws.receive(10)
83
+ if data:
84
+ obj = json.loads(data)
85
+
86
+ if "id" not in obj or "action" not in obj or "broadcast" not in obj:
87
+ ws.close(
88
+ 1008,
89
+ ws_response(
90
+ "error",
91
+ error=True,
92
+ status=400,
93
+ message="Sent data is invalid.",
94
+ ),
95
+ )
96
+ return
97
+
98
+ outstanding_actions = check_action(
99
+ obj["id"], obj["action"], obj["broadcast"], outstanding_actions=outstanding_actions, **kwargs
100
+ )
101
+ else:
102
+ logger.debug(ws_id + " listening")
103
+ except Exception as e:
104
+ if isinstance(e, ConnectionClosed):
105
+ raise
106
+ else:
107
+ logger.exception("Exception on connect.")
108
+ finally:
109
+ event_service.off("hits", send_hit)
110
+ event_service.off("broadcast", send_broadcast)
111
+ event_service.off("action", send_action)
112
+
113
+ for id, action, broadcast in outstanding_actions:
114
+ outstanding_actions = check_action(id, action, broadcast, outstanding_actions=outstanding_actions, **kwargs)