clue-api 1.3.0.dev92__tar.gz → 1.3.0.dev102__tar.gz
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.
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/PKG-INFO +2 -1
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/actions.py +33 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/app.py +6 -1
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/regex.py +0 -2
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/constants/supported_types.py +10 -9
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/extensions/config.py +4 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/actions.py +14 -3
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/__init__.py +138 -1
- clue_api-1.3.0.dev102/clue/plugin/celery_app.py +29 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/action_service.py +58 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/pyproject.toml +2 -1
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/LICENSE +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/README.md +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/.gitignore +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/base.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/auth.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/configs.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/fetchers.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/lookup.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/registration.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/api/v1/static.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/cache/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/classification.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/classification.yml +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/dict_utils.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/exceptions.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/forge.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/json_utils.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/list_utils.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/logging/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/logging/audit.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/logging/format.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/str_utils.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/swagger.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/common/uid.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/config.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/constants/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/constants/env.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/cronjobs/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/cronjobs/plugins.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/error.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/extensions/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/gunicorn_config.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/healthz.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/helper/discover.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/helper/headers.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/helper/oauth.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/config.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/fetchers.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/graph.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/model_list.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/network.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/base.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/graph.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/image.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/status.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/results/validation.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/selector.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/models/validators.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/patched.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/central_server.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/email_render.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/token.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/helpers/trino.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/models.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/plugin/utils.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/py.typed +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/cache.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/events.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/hash.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/queues/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/queues/comms.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/set.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/remote/datatypes/user_quota_tracker.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/security/__init__.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/security/obo.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/security/utils.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/auth_service.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/config_service.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/fetcher_service.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/jwt_service.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/lookup_service.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/type_service.py +0 -0
- {clue_api-1.3.0.dev92 → clue_api-1.3.0.dev102}/clue/services/user_service.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clue-api
|
|
3
|
-
Version: 1.3.0.
|
|
3
|
+
Version: 1.3.0.dev102
|
|
4
4
|
Summary: Clue distributed enrichment service
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -24,6 +24,7 @@ Requires-Dist: authlib (<2.0.0) ; extra == "server"
|
|
|
24
24
|
Requires-Dist: bcrypt (>=4.1.2,<5.0.0) ; extra == "server"
|
|
25
25
|
Requires-Dist: beautifulsoup4 (>=4.13.3,<5.0.0)
|
|
26
26
|
Requires-Dist: cart (>=1.2.3,<2.0.0)
|
|
27
|
+
Requires-Dist: celery (>=5.6.2,<6.0.0)
|
|
27
28
|
Requires-Dist: elastic-apm (>=6.22.0,<7.0.0)
|
|
28
29
|
Requires-Dist: flasgger (>=0.9.7.1,<0.10.0.0) ; extra == "server"
|
|
29
30
|
Requires-Dist: flask (<3.0.0)
|
|
@@ -90,3 +90,36 @@ def execute_action(plugin_id: str, action_id: str, **kwargs) -> ActionResult:
|
|
|
90
90
|
return not_found(err=err.message)
|
|
91
91
|
except ClueException as err:
|
|
92
92
|
return internal_error(err=err.message)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@generate_swagger_docs(responses={200: "Successfully fetched status of action"})
|
|
96
|
+
@actions_api.route("/<plugin_id>/<action_id>/status/<task_id>", methods=["GET"])
|
|
97
|
+
@api_login()
|
|
98
|
+
def get_action_status(plugin_id: str, action_id: str, task_id: str, **kwargs) -> ActionResult:
|
|
99
|
+
"""Get the status or result of a running action.
|
|
100
|
+
|
|
101
|
+
Variables:
|
|
102
|
+
plugin_id (str): the ID of the plugin who owns the action to execute
|
|
103
|
+
action_id (str): the ID of the action to execute
|
|
104
|
+
task_id (str): the ID of the specific task to get the status of
|
|
105
|
+
|
|
106
|
+
Arguments:
|
|
107
|
+
task_id (str): the celery task id to get the status of
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
Result Example:
|
|
111
|
+
{
|
|
112
|
+
"outcome": "success | failure | pending", # was this execution a success or failure or is it still pending?
|
|
113
|
+
"format": "link", # What format is the output in?
|
|
114
|
+
"output": "http://example.com" # The output of the action. Can be any data structure.
|
|
115
|
+
"task_id": if the action is still running, what is the task id so that we can fetch the status again
|
|
116
|
+
}
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
if not task_id:
|
|
120
|
+
return internal_error(err="no task_id found in url. task_id is required for this request.")
|
|
121
|
+
return ok(action_service.get_action_status(plugin_id, action_id, task_id, kwargs["user"]))
|
|
122
|
+
except NotFoundException as err:
|
|
123
|
+
return not_found(err=err.message)
|
|
124
|
+
except ClueException as err:
|
|
125
|
+
return internal_error(err=err.message)
|
|
@@ -2,6 +2,8 @@ import warnings
|
|
|
2
2
|
|
|
3
3
|
from gevent import monkey
|
|
4
4
|
|
|
5
|
+
from clue.constants.supported_types import SUPPORTED_TYPES
|
|
6
|
+
|
|
5
7
|
monkey.patch_all()
|
|
6
8
|
|
|
7
9
|
import os
|
|
@@ -132,7 +134,8 @@ app.register_blueprint(registration_api)
|
|
|
132
134
|
app.register_blueprint(static_api)
|
|
133
135
|
|
|
134
136
|
|
|
135
|
-
logger.info("Checking extensions for additional routes")
|
|
137
|
+
logger.info("Checking extensions for initialization and additional routes")
|
|
138
|
+
num_buildin_types = len(SUPPORTED_TYPES)
|
|
136
139
|
for extension in get_extensions():
|
|
137
140
|
if extension.modules.init:
|
|
138
141
|
extension.modules.init(flask_app=app)
|
|
@@ -144,6 +147,8 @@ for extension in get_extensions():
|
|
|
144
147
|
logger.info("Enabling additional endpoint: %s", route.url_prefix)
|
|
145
148
|
app.register_blueprint(route)
|
|
146
149
|
|
|
150
|
+
logger.info("%s types configured (%s custom types)", len(SUPPORTED_TYPES), len(SUPPORTED_TYPES) - num_buildin_types)
|
|
151
|
+
|
|
147
152
|
# Setup OAuth providers
|
|
148
153
|
if config.auth.oauth.enabled:
|
|
149
154
|
providers = []
|
|
@@ -40,5 +40,3 @@ URI_ONLY = f"^{URI_REGEX}$"
|
|
|
40
40
|
UUID4_REGEX = r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
|
|
41
41
|
|
|
42
42
|
EMAIL_PATH_REGEX = r"^[A-Z]+_EMAIL://.*"
|
|
43
|
-
HBS_AGENT_ID_REGEX = r"[0-9a-fA-F]{1,4}\.[0-9a-fA-F]{1,4}\.[0-9a-fA-F]{1,4}\.[0-9a-fA-F]{1,4}"
|
|
44
|
-
HBS_AGENT_ID_ONLY_REGEX = f"^{HBS_AGENT_ID_REGEX}$"
|
|
@@ -3,7 +3,6 @@ from clue.common.regex import (
|
|
|
3
3
|
DOMAIN_ONLY_REGEX,
|
|
4
4
|
EMAIL_PATH_REGEX,
|
|
5
5
|
EMAIL_REGEX,
|
|
6
|
-
HBS_AGENT_ID_REGEX,
|
|
7
6
|
IPV4_ONLY_REGEX,
|
|
8
7
|
IPV6_ONLY_REGEX,
|
|
9
8
|
MD5_REGEX,
|
|
@@ -32,16 +31,17 @@ SUPPORTED_TYPES = {
|
|
|
32
31
|
"md5": MD5_REGEX,
|
|
33
32
|
"sha1": SHA1_REGEX,
|
|
34
33
|
"sha256": SHA256_REGEX,
|
|
35
|
-
"hbs_oid": None,
|
|
36
|
-
"hbs_agent_id": HBS_AGENT_ID_REGEX,
|
|
37
34
|
"telemetry": None,
|
|
38
|
-
"howler_id": None,
|
|
39
35
|
"hostname": None,
|
|
40
36
|
"tenant-id": UUID4_REGEX,
|
|
41
37
|
}
|
|
42
38
|
|
|
39
|
+
CASE_INSENSITIVE_TYPES = ["ip", "domain", "port", "tenant-id", "hbs_oid", "hbs_agent_id"]
|
|
40
|
+
|
|
43
41
|
|
|
44
|
-
def add_supported_type(
|
|
42
|
+
def add_supported_type(
|
|
43
|
+
type: str, regex: str | None = None, namespace: str | None = None, case_insensitive: bool = False
|
|
44
|
+
):
|
|
45
45
|
r"""Add a supported type to the SUPPORTED_TYPES registry.
|
|
46
46
|
|
|
47
47
|
This function registers a new type with an optional regex pattern for validation.
|
|
@@ -62,10 +62,11 @@ def add_supported_type(type: str, regex: str | None = None, namespace: str | Non
|
|
|
62
62
|
"""
|
|
63
63
|
if not namespace:
|
|
64
64
|
logger.info("Adding new type %s to the default namespace with regex %s", type, regex)
|
|
65
|
-
|
|
65
|
+
new_entry = type
|
|
66
66
|
else:
|
|
67
67
|
logger.info("Adding type %s to namespace %s with regex %s", type, namespace, regex)
|
|
68
|
-
|
|
68
|
+
new_entry = f"{namespace}/{type}"
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
SUPPORTED_TYPES[new_entry] = regex
|
|
71
|
+
if case_insensitive:
|
|
72
|
+
CASE_INSENSITIVE_TYPES.append(new_entry)
|
|
@@ -59,6 +59,10 @@ class BaseExtensionConfig(BaseSettings):
|
|
|
59
59
|
|
|
60
60
|
data["modules"]["routes"] = new_routes
|
|
61
61
|
|
|
62
|
+
if "init" in data["modules"]:
|
|
63
|
+
if isinstance(data["modules"]["init"], bool):
|
|
64
|
+
data["modules"]["init"] = f"{plugin_name}.init:initialize"
|
|
65
|
+
|
|
62
66
|
if "obo_module" in data["modules"]:
|
|
63
67
|
if isinstance(data["modules"]["obo_module"], bool):
|
|
64
68
|
data["modules"]["obo_module"] = f"{plugin_name}.obo:get_obo_token"
|
|
@@ -69,6 +69,10 @@ class ActionContextInformation(BaseModel):
|
|
|
69
69
|
ActionContextInformationType = TypeVar("ActionContextInformationType", bound=ActionContextInformation)
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
class ActionStatusRequest(BaseModel):
|
|
73
|
+
task_id: str = Field(description="The task id to get the status for.")
|
|
74
|
+
|
|
75
|
+
|
|
72
76
|
class ExecuteRequest(BaseModel):
|
|
73
77
|
context: ActionContextInformation | None = Field(
|
|
74
78
|
description="Contextual information on where the action is being executed (if provided)", default=None
|
|
@@ -123,6 +127,7 @@ class ActionBase(BaseModel):
|
|
|
123
127
|
)
|
|
124
128
|
accept_empty: bool = Field(description="Does this action support execution with no selectors?", default=False)
|
|
125
129
|
accept_multiple: bool = Field(description="Does this action support multiple values?", default=False)
|
|
130
|
+
async_result: bool = Field(description="Does this action run asynchronously?", default=False)
|
|
126
131
|
format: str | None = Field(
|
|
127
132
|
description="What is the format of the output, if known?",
|
|
128
133
|
default=None,
|
|
@@ -242,7 +247,9 @@ class Action(ActionBase, Generic[ER]):
|
|
|
242
247
|
|
|
243
248
|
|
|
244
249
|
class ActionResult(BaseModel, Generic[DATA]):
|
|
245
|
-
outcome: Union[Literal["success"], Literal["failure"]] = Field(
|
|
250
|
+
outcome: Union[Literal["success"], Literal["failure"], Literal["pending"]] = Field(
|
|
251
|
+
description="Did the action succeed/fail, or is it pending?"
|
|
252
|
+
)
|
|
246
253
|
summary: str | None = Field(description="Message explaining the outcome of the action.", default=None)
|
|
247
254
|
output: DATA | Url | None = Field(description="The output of the action.", default=None)
|
|
248
255
|
format: str | None = Field(
|
|
@@ -251,6 +258,7 @@ class ActionResult(BaseModel, Generic[DATA]):
|
|
|
251
258
|
default=None,
|
|
252
259
|
)
|
|
253
260
|
link: Url | None = Field(description="Link to more information on the outcome of the action", default=None)
|
|
261
|
+
task_id: str | None = Field(description="The celery task id if the action is pending.", default=None)
|
|
254
262
|
|
|
255
263
|
@model_validator(mode="after")
|
|
256
264
|
def validate_model(self: Self, info: ValidationInfo) -> Self: # noqa: C901
|
|
@@ -262,8 +270,11 @@ class ActionResult(BaseModel, Generic[DATA]):
|
|
|
262
270
|
Returns:
|
|
263
271
|
Self: The validated model.
|
|
264
272
|
"""
|
|
265
|
-
if not self.format and self.outcome
|
|
266
|
-
raise ClueValueError("You must set a format if outcome is
|
|
273
|
+
if not self.format and self.outcome == "success":
|
|
274
|
+
raise ClueValueError("You must set a format if outcome is success.")
|
|
275
|
+
|
|
276
|
+
if not self.task_id and self.outcome == "pending":
|
|
277
|
+
raise ClueValueError("task_id must be set if outcome is pending.")
|
|
267
278
|
|
|
268
279
|
if self.format == "pivot" and (not self.output or not isinstance(self.output, Url)):
|
|
269
280
|
if isinstance(self.output, str):
|
|
@@ -32,11 +32,14 @@ from clue.models.actions import (
|
|
|
32
32
|
ActionBase,
|
|
33
33
|
ActionResult,
|
|
34
34
|
ActionSpec,
|
|
35
|
+
ActionStatusRequest,
|
|
35
36
|
ExecuteRequest,
|
|
36
37
|
)
|
|
38
|
+
from clue.models.config import Config
|
|
37
39
|
from clue.models.fetchers import FetcherDefinition, FetcherResult
|
|
38
40
|
from clue.models.network import QueryEntry
|
|
39
41
|
from clue.models.selector import Selector
|
|
42
|
+
from clue.plugin.celery_app import celery_init_app
|
|
40
43
|
from clue.plugin.helpers.token import get_username
|
|
41
44
|
from clue.plugin.models import BulkEntry
|
|
42
45
|
from clue.plugin.utils import Params
|
|
@@ -52,6 +55,7 @@ OVERRIDABLE_FUNCTIONS = [
|
|
|
52
55
|
"liveness", # Kubernetes liveness probe endpoint
|
|
53
56
|
"readiness", # Kubernetes readiness probe endpoint
|
|
54
57
|
"run_action", # Function to execute plugin actions
|
|
58
|
+
"get_status", # Function to check the status or result of a pending action
|
|
55
59
|
"run_fetcher", # Function to execute plugin fetchers
|
|
56
60
|
"setup_actions", # Runtime action definition generation
|
|
57
61
|
"validate_token", # Custom authentication token validation
|
|
@@ -130,6 +134,37 @@ def build_default_logger() -> logging.Logger:
|
|
|
130
134
|
return logger
|
|
131
135
|
|
|
132
136
|
|
|
137
|
+
config: Config = Config()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_app(app_name: str, enable_celery: bool = False, tasks: list[str] | None = None):
|
|
141
|
+
"""helper function to create the flask app and set up the celery config if enabled
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
enable_celery (bool): whether or not to enable celery
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
_type_: flask app
|
|
148
|
+
"""
|
|
149
|
+
app = Flask(__name__.split(".")[0])
|
|
150
|
+
if enable_celery:
|
|
151
|
+
redis_url = (
|
|
152
|
+
f"redis://:{config.core.redis.password}@{config.core.redis.host}:{config.core.redis.port}"
|
|
153
|
+
if config.core.redis.password
|
|
154
|
+
else f"redis://{config.core.redis.host}:{config.core.redis.port}"
|
|
155
|
+
)
|
|
156
|
+
app.config.from_mapping(
|
|
157
|
+
CELERY=dict(
|
|
158
|
+
broker_url=redis_url,
|
|
159
|
+
result_backend=redis_url,
|
|
160
|
+
result_backend_transport_options={"global_keyprefix": app_name + "_results"},
|
|
161
|
+
result_expires=3600, # expire results after one hour
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
celery_init_app(app, tasks)
|
|
165
|
+
return app
|
|
166
|
+
|
|
167
|
+
|
|
133
168
|
class CluePlugin:
|
|
134
169
|
"""Helper class for creating clue plugins with proper server responses and behaviour.
|
|
135
170
|
|
|
@@ -259,6 +294,13 @@ class CluePlugin:
|
|
|
259
294
|
user parameters) that instance will be passed instead, and casting the argument will be necessary.
|
|
260
295
|
"""
|
|
261
296
|
|
|
297
|
+
get_status: Callable[[Action, ActionStatusRequest, str | None], ActionResult] | None
|
|
298
|
+
"""The function to get the status and result of running actions.
|
|
299
|
+
|
|
300
|
+
Accepts the selected action definition as well as an ActionStatusRequest instance which contains the
|
|
301
|
+
specific task id to check the status of.
|
|
302
|
+
"""
|
|
303
|
+
|
|
262
304
|
fetchers: list[FetcherDefinition] | None
|
|
263
305
|
"A list of fetcher definitions this plugin supports."
|
|
264
306
|
|
|
@@ -275,6 +317,9 @@ class CluePlugin:
|
|
|
275
317
|
readiness: Callable[[], Response]
|
|
276
318
|
"A readiness probe for kubernetes implementations of clue."
|
|
277
319
|
|
|
320
|
+
enable_celery: bool
|
|
321
|
+
"Flag to enable celery in plugin"
|
|
322
|
+
|
|
278
323
|
def __init__(
|
|
279
324
|
self: Self,
|
|
280
325
|
app_name: str,
|
|
@@ -284,6 +329,7 @@ class CluePlugin:
|
|
|
284
329
|
classification: str | None = os.environ.get("CLASSIFICATION", None),
|
|
285
330
|
enable_apm: bool = False,
|
|
286
331
|
enable_cache: Union[bool, Literal["redis"], Literal["local"]] = True,
|
|
332
|
+
enable_celery: bool = False,
|
|
287
333
|
enrich: Callable[[str, str, Params, str | None], Union[list[QueryEntry], QueryEntry]] | None = None,
|
|
288
334
|
fetchers: list[FetcherDefinition] | None = None,
|
|
289
335
|
liveness: Callable[[], Response] = default_liveness,
|
|
@@ -291,6 +337,7 @@ class CluePlugin:
|
|
|
291
337
|
logger: logging.Logger | None = None,
|
|
292
338
|
readiness: Callable[[], Response] = default_readiness,
|
|
293
339
|
run_action: Callable[[Action, ExecuteRequest, str | None], ActionResult] | None = None,
|
|
340
|
+
get_status: Callable[[Action, ActionStatusRequest, str | None], ActionResult] | None = None,
|
|
294
341
|
run_fetcher: Callable[[FetcherDefinition, Selector, str | None], FetcherResult] | None = None,
|
|
295
342
|
setup_actions: Callable[[list[Action], str | None], list[Action]] | None = None,
|
|
296
343
|
supported_types: set[str] | str | None = None,
|
|
@@ -360,7 +407,7 @@ class CluePlugin:
|
|
|
360
407
|
"""
|
|
361
408
|
self.alternate_bulk_lookup = alternate_bulk_lookup
|
|
362
409
|
# Create Flask app using the module name (before first dot) as app name
|
|
363
|
-
self.app =
|
|
410
|
+
self.app = create_app(app_name, enable_celery)
|
|
364
411
|
self.app_name = app_name
|
|
365
412
|
|
|
366
413
|
# Classification is required for security - must be specified via env var or parameter
|
|
@@ -389,7 +436,9 @@ class CluePlugin:
|
|
|
389
436
|
self.logger = logger if logger else build_default_logger()
|
|
390
437
|
|
|
391
438
|
self.enrich = enrich
|
|
439
|
+
self.enable_celery = enable_celery
|
|
392
440
|
self.run_action = run_action
|
|
441
|
+
self.get_status = get_status
|
|
393
442
|
self.validate_token = validate_token
|
|
394
443
|
|
|
395
444
|
self.fetchers = fetchers
|
|
@@ -662,6 +711,12 @@ class CluePlugin:
|
|
|
662
711
|
self.app.add_url_rule(
|
|
663
712
|
"/actions/<action_id>/", self.execute_action.__name__, self.execute_action, methods=["POST"]
|
|
664
713
|
)
|
|
714
|
+
self.app.add_url_rule(
|
|
715
|
+
"/actions/<action_id>/status/<task_id>",
|
|
716
|
+
self.get_action_status.__name__,
|
|
717
|
+
self.get_action_status,
|
|
718
|
+
methods=["GET"],
|
|
719
|
+
)
|
|
665
720
|
self.app.add_url_rule("/fetchers/", self.get_fetchers.__name__, self.get_fetchers, methods=["GET"])
|
|
666
721
|
self.app.add_url_rule(
|
|
667
722
|
"/fetchers/<fetcher_id>", self.execute_fetcher.__name__, self.execute_fetcher, methods=["POST"]
|
|
@@ -1128,6 +1183,88 @@ class CluePlugin:
|
|
|
1128
1183
|
|
|
1129
1184
|
return self.make_api_response(result)
|
|
1130
1185
|
|
|
1186
|
+
def get_action_status(self: Self, action_id: str, task_id: str): # noqa: C901
|
|
1187
|
+
"""Retrieves the status of the specified action.
|
|
1188
|
+
|
|
1189
|
+
Args:
|
|
1190
|
+
action_id (str): The ID of the action to get the status for
|
|
1191
|
+
task_id (str): The celery task id to get the result from
|
|
1192
|
+
|
|
1193
|
+
Returns:
|
|
1194
|
+
Response: A Response object with an ActionResult as the body.
|
|
1195
|
+
"""
|
|
1196
|
+
if not task_id:
|
|
1197
|
+
return self.make_api_response(
|
|
1198
|
+
{}, err="task id not provided. task id is required for this request.", status_code=400
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
if not self.get_status:
|
|
1202
|
+
return self.make_api_response(
|
|
1203
|
+
{}, err=f"{self.app_name} does not support the get action status functions.", status_code=400
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
try:
|
|
1207
|
+
actions = self.__check_actions()
|
|
1208
|
+
except Exception:
|
|
1209
|
+
self.logger.exception("Exception on setup actions:")
|
|
1210
|
+
|
|
1211
|
+
return self.make_api_response({}, err="Error on action setup.", status_code=500)
|
|
1212
|
+
|
|
1213
|
+
if actions is None:
|
|
1214
|
+
actions = self.actions or []
|
|
1215
|
+
|
|
1216
|
+
action_to_check = next((action for action in actions if action.id == action_id), None)
|
|
1217
|
+
if not action_to_check:
|
|
1218
|
+
return self.make_api_response({}, err="Action does not exist", status_code=404)
|
|
1219
|
+
|
|
1220
|
+
token: str | None = None
|
|
1221
|
+
if self.validate_token:
|
|
1222
|
+
self.logger.debug("Executing plugin-provided token validator")
|
|
1223
|
+
|
|
1224
|
+
token, error = self.validate_token()
|
|
1225
|
+
|
|
1226
|
+
if error:
|
|
1227
|
+
return self.make_api_response(None, f"Error on token validation: {error}", status_code=401)
|
|
1228
|
+
|
|
1229
|
+
self.logger.debug("Token is valid")
|
|
1230
|
+
else:
|
|
1231
|
+
self.logger.warning("No token validation provided. The access token will not be provided to the action.")
|
|
1232
|
+
|
|
1233
|
+
try:
|
|
1234
|
+
# Validate request body against the action's parameter schema
|
|
1235
|
+
status_request = ActionStatusRequest(task_id=task_id)
|
|
1236
|
+
|
|
1237
|
+
self.logger.info(
|
|
1238
|
+
"Getting status for Action '%s' with task_id: %s",
|
|
1239
|
+
action_id,
|
|
1240
|
+
task_id,
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
result = self.get_status(action_to_check, status_request, token)
|
|
1244
|
+
except json.JSONDecodeError as e:
|
|
1245
|
+
self.logger.warning("JSON decoding error while getting status: %s", str(e))
|
|
1246
|
+
|
|
1247
|
+
result = ActionResult(
|
|
1248
|
+
outcome="failure",
|
|
1249
|
+
summary=f"Invalid request format. Request body must be valid JSON. Error: {str(e)}",
|
|
1250
|
+
)
|
|
1251
|
+
except ValidationError as err:
|
|
1252
|
+
self.logger.warning("Validation error during execution: %s", str(err))
|
|
1253
|
+
|
|
1254
|
+
result = ActionResult(outcome="failure", summary=f"Validation error: {str(err)}")
|
|
1255
|
+
except ClueException as e:
|
|
1256
|
+
self.logger.exception("ClueException during execution:")
|
|
1257
|
+
|
|
1258
|
+
result = ActionResult(outcome="failure", summary=f"Error encountered during execution: {e.message}")
|
|
1259
|
+
except Exception as e:
|
|
1260
|
+
self.logger.exception("%s during execution:", e.__class__.__name__)
|
|
1261
|
+
|
|
1262
|
+
result = ActionResult(outcome="failure", summary=f"An unknown error occurred during execution: {str(e)}")
|
|
1263
|
+
|
|
1264
|
+
self.logger.info("Action status: %s", result.outcome)
|
|
1265
|
+
|
|
1266
|
+
return self.make_api_response(result)
|
|
1267
|
+
|
|
1131
1268
|
def get_fetchers(self: Self) -> Response:
|
|
1132
1269
|
"""Get all available fetchers for this plugin.
|
|
1133
1270
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from celery import Celery, Task
|
|
4
|
+
from flask import Flask
|
|
5
|
+
|
|
6
|
+
celery = Celery("app")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def celery_init_app(app: Flask, tasks: List[str] | None = None) -> None:
|
|
10
|
+
"""initialize the celery worker for the flask app
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
app (Flask): flask app instance
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
class FlaskTask(Task):
|
|
17
|
+
def __call__(self, *args: object, **kwargs: object) -> object:
|
|
18
|
+
with app.app_context():
|
|
19
|
+
return self.run(*args, **kwargs)
|
|
20
|
+
|
|
21
|
+
celery_app = Celery(app.name, task_cls=FlaskTask)
|
|
22
|
+
celery_app.config_from_object(app.config["CELERY"])
|
|
23
|
+
celery_app.set_default()
|
|
24
|
+
if tasks:
|
|
25
|
+
celery_app.autodiscover_tasks(
|
|
26
|
+
tasks,
|
|
27
|
+
force=True,
|
|
28
|
+
)
|
|
29
|
+
app.extensions["celery"] = celery_app
|
|
@@ -184,3 +184,61 @@ def execute_action(plugin_id: str, action_id: str, user: dict[str, Any]) -> Acti
|
|
|
184
184
|
raise ClueException(
|
|
185
185
|
f"Something went wrong when retrieving the result from plugin '{plugin_id}': {err.__class__.__name__}."
|
|
186
186
|
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_action_status(plugin_id: str, action_id: str, task_id: str, user: dict[str, Any]) -> ActionResult:
|
|
190
|
+
"""Gets the status of a specified action with task_id.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
plugin_id (str): The ID of the plugin.
|
|
194
|
+
action_id (str): The ID of the action to run.
|
|
195
|
+
task_id (str): The celery task id to fetch the status for
|
|
196
|
+
user (dict[str, Any]): The user dict of the user running the action.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
NotFoundException: Raised whenever the plugin or the action doesn't exist.
|
|
200
|
+
ClueException: Raised whenever an error is returned by the plugin endpoint.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
ActionResult: The result of the action.
|
|
204
|
+
"""
|
|
205
|
+
plugin = next((source for source in config.api.external_sources if source.name == plugin_id), None)
|
|
206
|
+
|
|
207
|
+
if not plugin:
|
|
208
|
+
raise NotFoundException(f"Plugin {plugin_id} does not exist.")
|
|
209
|
+
|
|
210
|
+
access_token = request.headers.get("Authorization", type=str)
|
|
211
|
+
if access_token:
|
|
212
|
+
access_token = access_token.split(" ")[1]
|
|
213
|
+
|
|
214
|
+
obo_access_token = None
|
|
215
|
+
if access_token:
|
|
216
|
+
obo_access_token, error = auth_service.check_obo(plugin, access_token, user["uname"])
|
|
217
|
+
|
|
218
|
+
if error:
|
|
219
|
+
logger.error("%s: %s", plugin.name, error)
|
|
220
|
+
return ActionResult(outcome="failure", summary="Invalid token provided.")
|
|
221
|
+
|
|
222
|
+
headers = generate_headers(obo_access_token or access_token, access_token if obo_access_token else None)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
req_url = urljoin(plugin.url, f"actions/{action_id}/status/{task_id}")
|
|
226
|
+
logger.debug("Getting status for action %s with task_id %s for user %s", req_url, task_id, user["uname"])
|
|
227
|
+
|
|
228
|
+
response = requests.get(
|
|
229
|
+
req_url,
|
|
230
|
+
headers=headers,
|
|
231
|
+
timeout=request.args.get("max_timeout", plugin.default_timeout, type=float),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
result = response.json()
|
|
235
|
+
|
|
236
|
+
if not response.ok:
|
|
237
|
+
raise ClueException(result["api_error_message"])
|
|
238
|
+
|
|
239
|
+
return ActionResult.model_validate(result["api_response"])
|
|
240
|
+
except (JSONDecodeError, exceptions.ConnectionError) as err:
|
|
241
|
+
logger.exception(f"Something went wrong when retrieving the status from plugin '{plugin_id}'")
|
|
242
|
+
raise ClueException(
|
|
243
|
+
f"Something went wrong when retrieving the status from plugin '{plugin_id}': {err.__class__.__name__}."
|
|
244
|
+
)
|
|
@@ -142,7 +142,7 @@ log_cli_level = "WARN"
|
|
|
142
142
|
[tool.poetry]
|
|
143
143
|
package-mode = true
|
|
144
144
|
name = "clue-api"
|
|
145
|
-
version = "1.3.0.
|
|
145
|
+
version = "1.3.0.dev102"
|
|
146
146
|
description = "Clue distributed enrichment service"
|
|
147
147
|
authors = ["Canadian Centre for Cyber Security <contact@cyber.gc.ca>"]
|
|
148
148
|
license = "MIT"
|
|
@@ -204,6 +204,7 @@ authlib = { version = "<2.0.0", optional = true }
|
|
|
204
204
|
flask-cors = { version = ">=4.0.1,<7.0.0", optional = true }
|
|
205
205
|
flasgger = { version = "^0.9.7.1", optional = true }
|
|
206
206
|
trino = "^0.336.0"
|
|
207
|
+
celery = "^5.6.2"
|
|
207
208
|
|
|
208
209
|
[tool.poetry.extras]
|
|
209
210
|
server = [
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|