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.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +167 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/borealis.py +101 -0
- howler/api/v1/configs.py +55 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +715 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +414 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +144 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/hexdump.py +48 -0
- howler/common/iprange.py +171 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2327 -0
- howler/datastore/constants.py +117 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +214 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +46 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1504 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +33 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +606 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +330 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-2.13.0.dev329.dist-info/METADATA +71 -0
- howler_api-2.13.0.dev329.dist-info/RECORD +200 -0
- howler_api-2.13.0.dev329.dist-info/WHEEL +4 -0
- 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)
|