clue-api 1.0.0.dev7__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.
- clue/.gitignore +21 -0
- clue/__init__.py +0 -0
- clue/api/__init__.py +211 -0
- clue/api/base.py +99 -0
- clue/api/v1/__init__.py +82 -0
- clue/api/v1/actions.py +92 -0
- clue/api/v1/auth.py +243 -0
- clue/api/v1/configs.py +83 -0
- clue/api/v1/fetchers.py +94 -0
- clue/api/v1/lookup.py +221 -0
- clue/api/v1/registration.py +109 -0
- clue/api/v1/static.py +94 -0
- clue/app.py +166 -0
- clue/cache/__init__.py +129 -0
- clue/common/__init__.py +0 -0
- clue/common/classification.py +1006 -0
- clue/common/classification.yml +130 -0
- clue/common/dict_utils.py +130 -0
- clue/common/exceptions.py +199 -0
- clue/common/forge.py +152 -0
- clue/common/json_utils.py +10 -0
- clue/common/list_utils.py +11 -0
- clue/common/logging/__init__.py +291 -0
- clue/common/logging/audit.py +157 -0
- clue/common/logging/format.py +42 -0
- clue/common/regex.py +31 -0
- clue/common/str_utils.py +213 -0
- clue/common/swagger.py +139 -0
- clue/common/uid.py +47 -0
- clue/config.py +60 -0
- clue/constants/__init__.py +0 -0
- clue/constants/supported_types.py +38 -0
- clue/cronjobs/__init__.py +30 -0
- clue/cronjobs/plugins.py +32 -0
- clue/error.py +129 -0
- clue/gunicorn_config.py +29 -0
- clue/healthz.py +74 -0
- clue/helper/discover.py +53 -0
- clue/helper/headers.py +30 -0
- clue/helper/oauth.py +128 -0
- clue/models/__init__.py +0 -0
- clue/models/actions.py +243 -0
- clue/models/config.py +456 -0
- clue/models/fetchers.py +136 -0
- clue/models/graph.py +162 -0
- clue/models/model_list.py +52 -0
- clue/models/network.py +430 -0
- clue/models/results/__init__.py +34 -0
- clue/models/results/base.py +10 -0
- clue/models/results/graph.py +26 -0
- clue/models/results/image.py +22 -0
- clue/models/results/status.py +55 -0
- clue/models/results/validation.py +57 -0
- clue/models/selector.py +67 -0
- clue/models/utils.py +52 -0
- clue/models/validators.py +19 -0
- clue/patched.py +8 -0
- clue/plugin/__init__.py +1008 -0
- clue/plugin/helpers/__init__.py +0 -0
- clue/plugin/helpers/central_server.py +27 -0
- clue/plugin/helpers/email_render.py +228 -0
- clue/plugin/helpers/token.py +34 -0
- clue/plugin/helpers/trino.py +103 -0
- clue/plugin/interactive.py +270 -0
- clue/plugin/models.py +19 -0
- clue/plugin/utils.py +78 -0
- clue/remote/__init__.py +0 -0
- clue/remote/datatypes/__init__.py +130 -0
- clue/remote/datatypes/cache.py +62 -0
- clue/remote/datatypes/events.py +118 -0
- clue/remote/datatypes/hash.py +193 -0
- clue/remote/datatypes/queues/__init__.py +0 -0
- clue/remote/datatypes/queues/comms.py +62 -0
- clue/remote/datatypes/set.py +96 -0
- clue/remote/datatypes/user_quota_tracker.py +54 -0
- clue/security/__init__.py +211 -0
- clue/security/obo.py +95 -0
- clue/security/utils.py +34 -0
- clue/services/action_service.py +186 -0
- clue/services/auth_service.py +348 -0
- clue/services/config_service.py +38 -0
- clue/services/fetcher_service.py +203 -0
- clue/services/jwt_service.py +233 -0
- clue/services/lookup_service.py +786 -0
- clue/services/type_service.py +165 -0
- clue/services/user_service.py +152 -0
- clue_api-1.0.0.dev7.dist-info/METADATA +111 -0
- clue_api-1.0.0.dev7.dist-info/RECORD +91 -0
- clue_api-1.0.0.dev7.dist-info/WHEEL +4 -0
- clue_api-1.0.0.dev7.dist-info/entry_points.txt +8 -0
- clue_api-1.0.0.dev7.dist-info/licenses/LICENSE +11 -0
clue/api/v1/auth.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
from authlib.integrations.base_client import OAuthError
|
|
6
|
+
from flask import current_app, request
|
|
7
|
+
|
|
8
|
+
import clue.services.auth_service as auth_service
|
|
9
|
+
import clue.services.user_service as user_service
|
|
10
|
+
from clue.api import (
|
|
11
|
+
bad_request,
|
|
12
|
+
forbidden,
|
|
13
|
+
internal_error,
|
|
14
|
+
make_subapi_blueprint,
|
|
15
|
+
ok,
|
|
16
|
+
unauthorized,
|
|
17
|
+
)
|
|
18
|
+
from clue.common.exceptions import (
|
|
19
|
+
AccessDeniedException,
|
|
20
|
+
AuthenticationException,
|
|
21
|
+
ClueException,
|
|
22
|
+
ClueValueError,
|
|
23
|
+
InvalidDataException,
|
|
24
|
+
)
|
|
25
|
+
from clue.common.logging import get_logger
|
|
26
|
+
from clue.common.str_utils import default_string_value
|
|
27
|
+
from clue.common.swagger import generate_swagger_docs
|
|
28
|
+
from clue.config import config
|
|
29
|
+
from clue.security.utils import generate_random_secret
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__file__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SUB_API = "auth"
|
|
35
|
+
auth_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
36
|
+
auth_api._doc = "Allow user to authenticate to the web server"
|
|
37
|
+
|
|
38
|
+
logger = get_logger(__file__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# noinspection PyBroadException,PyPropertyAccess
|
|
42
|
+
@generate_swagger_docs()
|
|
43
|
+
@auth_api.route("/login", methods=["GET", "POST"])
|
|
44
|
+
def login(**_) -> dict[str, Any]: # noqa: C901
|
|
45
|
+
"""Log the user into the system, in one of three ways.
|
|
46
|
+
|
|
47
|
+
1. Username/Password Authentication
|
|
48
|
+
2. Username/API Key Authentication
|
|
49
|
+
3. OAuth Login flow
|
|
50
|
+
(See here: https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow)
|
|
51
|
+
|
|
52
|
+
Variables:
|
|
53
|
+
None
|
|
54
|
+
|
|
55
|
+
Arguments:
|
|
56
|
+
NOTE: The arguments are used only when completing the OAuth authorization flow.
|
|
57
|
+
provider => The provider of the OAuth code.
|
|
58
|
+
state => Random state used in the OAuth authentication flow.
|
|
59
|
+
code => The code provided by the OAuth provider used to exchange for an access token.
|
|
60
|
+
|
|
61
|
+
Data Block:
|
|
62
|
+
{
|
|
63
|
+
"user": "user", # The username to authenticate as (optional)
|
|
64
|
+
"password": "password", # The password used to authenticate (optional)
|
|
65
|
+
"apikey": "devkey:user", # The apikey used ot authenticate (optional)
|
|
66
|
+
"oauth_provider": "keycloak" # The oauth provider initiate an OAuth Authorization Flow with (optional)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Result Example:
|
|
70
|
+
{
|
|
71
|
+
# Profile picture for the user
|
|
72
|
+
"avatar": "data:image/png;base64, ...",
|
|
73
|
+
# Username of the authenticated user
|
|
74
|
+
"username": "user",
|
|
75
|
+
# Different privileges that the user will get for this session
|
|
76
|
+
"privileges": ["R", "W"],
|
|
77
|
+
# A token generated by us the user can use to authenticate with clue
|
|
78
|
+
"app_token": "asdfsd876opqwm465a89sdf4",
|
|
79
|
+
# A JSON Web Access Token generated by an OAuth provider to authenticate with them
|
|
80
|
+
"access_token": "<JWT>",
|
|
81
|
+
}
|
|
82
|
+
"""
|
|
83
|
+
data: dict[str, Any]
|
|
84
|
+
if request.is_json and len(request.data) > 0:
|
|
85
|
+
data = request.json # type: ignore
|
|
86
|
+
else:
|
|
87
|
+
data = request.values
|
|
88
|
+
|
|
89
|
+
# Get the ip the request came from - used in logging later
|
|
90
|
+
ip = request.headers.get("X-Forwarded-For", request.remote_addr)
|
|
91
|
+
|
|
92
|
+
# Get the data from the request
|
|
93
|
+
# TODO: Figure out how to fix this inconsistency
|
|
94
|
+
oauth_provider = data.get("provider", data.get("oauth_provider", None))
|
|
95
|
+
user = data.get("user", None)
|
|
96
|
+
data.get("password", None)
|
|
97
|
+
data.get("apikey", None)
|
|
98
|
+
|
|
99
|
+
# These variables are what will eventually be returned, if authentication is successful
|
|
100
|
+
logged_in_uname = None
|
|
101
|
+
access_token = None
|
|
102
|
+
refresh_token = data.get("refresh_token", None)
|
|
103
|
+
priv: Optional[list[str]] = []
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# First, we'll try oauth
|
|
107
|
+
if oauth_provider:
|
|
108
|
+
if not config.auth.oauth.enabled:
|
|
109
|
+
raise InvalidDataException("OAuth is disabled.")
|
|
110
|
+
|
|
111
|
+
oauth = current_app.extensions.get("authlib.integrations.flask_client")
|
|
112
|
+
if not oauth:
|
|
113
|
+
logger.critical("Authlib integration missing!")
|
|
114
|
+
raise ClueValueError()
|
|
115
|
+
|
|
116
|
+
provider = oauth.create_client(oauth_provider)
|
|
117
|
+
|
|
118
|
+
if not provider:
|
|
119
|
+
logger.critical("OAuth client failed to create!")
|
|
120
|
+
raise ClueValueError()
|
|
121
|
+
|
|
122
|
+
# This means that they want to start the oauth process, so we'll redirect them to their chosen provider
|
|
123
|
+
if "code" not in request.args and not refresh_token:
|
|
124
|
+
referer = request.headers.get("Referer", None)
|
|
125
|
+
uri = urlparse(referer if referer else request.host_url)
|
|
126
|
+
port_portion = ":" + str(uri.port) if uri.port else ""
|
|
127
|
+
redirect_uri = f"{uri.scheme}://{uri.hostname}{port_portion}/login?provider={oauth_provider}"
|
|
128
|
+
return provider.authorize_redirect(redirect_uri=redirect_uri)
|
|
129
|
+
|
|
130
|
+
# At this point we know the code exists, so we're good to use that to exchange for an JSON Web Token with
|
|
131
|
+
# user data in it. token_data contains the access token, expiry, refresh token, and id token,
|
|
132
|
+
# in JWT format: https://jwt.io/
|
|
133
|
+
|
|
134
|
+
oauth_provider_config = config.auth.oauth.providers[oauth_provider]
|
|
135
|
+
|
|
136
|
+
# We need to figure out what information the provider already has, and provide whatever it doesn't.
|
|
137
|
+
# Without this step, the provider will try and send the client_id and/or secret *twice*, leading to an
|
|
138
|
+
# error.
|
|
139
|
+
kwargs = {}
|
|
140
|
+
|
|
141
|
+
# Does the provider have the client id? If not provide it
|
|
142
|
+
if not provider.client_id:
|
|
143
|
+
kwargs["client_id"] = default_string_value(
|
|
144
|
+
oauth_provider_config.client_id,
|
|
145
|
+
env_name=f"{oauth_provider.upper()}_CLIENT_ID",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if not kwargs["client_id"]:
|
|
149
|
+
logger.critical("client id not set! Cannot complete oauth")
|
|
150
|
+
raise ClueValueError()
|
|
151
|
+
|
|
152
|
+
# Does the provider have the client secret? If not provide it
|
|
153
|
+
if not provider.client_secret:
|
|
154
|
+
kwargs["client_secret"] = default_string_value(
|
|
155
|
+
oauth_provider_config.client_secret,
|
|
156
|
+
env_name=f"{oauth_provider.upper()}_CLIENT_SECRET",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if not kwargs["client_secret"]:
|
|
160
|
+
logger.critical("client secret not set! Cannot complete oauth")
|
|
161
|
+
raise ClueValueError()
|
|
162
|
+
|
|
163
|
+
if refresh_token is not None:
|
|
164
|
+
token_data = provider.fetch_access_token(
|
|
165
|
+
refresh_token=refresh_token,
|
|
166
|
+
grant_type="refresh_token",
|
|
167
|
+
**kwargs,
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
# Finally, ask for the access token with whatever info the provider needs
|
|
171
|
+
token_data = provider.authorize_access_token(**kwargs)
|
|
172
|
+
|
|
173
|
+
access_token = token_data.get("access_token", None)
|
|
174
|
+
refresh_token = token_data.get("refresh_token", None)
|
|
175
|
+
|
|
176
|
+
# Get a useful dict of user data from the web token
|
|
177
|
+
cur_user = user_service.parse_user_data(token_data, oauth_provider)
|
|
178
|
+
|
|
179
|
+
logged_in_uname = cur_user["uname"]
|
|
180
|
+
|
|
181
|
+
priv = ["R", "W"]
|
|
182
|
+
|
|
183
|
+
# No oauth provider was specified, so we fall back to user/pass or user/apikey
|
|
184
|
+
# elif user and (password or apikey):
|
|
185
|
+
# if password and apikey:
|
|
186
|
+
# raise InvalidDataException("Cannot specify password and API key.")
|
|
187
|
+
|
|
188
|
+
# user_data, priv = auth_service.basic_auth(
|
|
189
|
+
# f"{user}:{password or apikey}",
|
|
190
|
+
# is_base64=False,
|
|
191
|
+
# # No need to validate for api keys if we know they provided a password, and vice versa
|
|
192
|
+
# skip_apikey=bool(password),
|
|
193
|
+
# skip_password=bool(apikey),
|
|
194
|
+
# )
|
|
195
|
+
|
|
196
|
+
# if not user_data:
|
|
197
|
+
# raise AuthenticationException("User does not exist, or authentication was invalid")
|
|
198
|
+
|
|
199
|
+
# logged_in_uname = user_data["uname"]
|
|
200
|
+
|
|
201
|
+
else:
|
|
202
|
+
raise AuthenticationException("Not enough information to proceed with authentication")
|
|
203
|
+
|
|
204
|
+
# For sanity's sake, we throw exceptions throughout the authentication code and simply catch the exceptions here to
|
|
205
|
+
# return the corresponding HTTP Code to the user
|
|
206
|
+
except (OAuthError, AuthenticationException) as err:
|
|
207
|
+
logger.warning(f"Authentication failure. (U:{user} - IP:{ip}) [{err}]")
|
|
208
|
+
return unauthorized(err=str(err))
|
|
209
|
+
|
|
210
|
+
except AccessDeniedException as err:
|
|
211
|
+
logger.warning(f"Authorization failure. (U:{user} - IP:{ip}) [{err}]")
|
|
212
|
+
return forbidden(err=err.message)
|
|
213
|
+
|
|
214
|
+
except InvalidDataException as err:
|
|
215
|
+
return bad_request(err=err.message or str(err))
|
|
216
|
+
|
|
217
|
+
except ClueException:
|
|
218
|
+
logger.exception(f"Internal Authentication Error. (U:{user} - IP:{ip})")
|
|
219
|
+
return internal_error(
|
|
220
|
+
err="Unhandled exception occured while Authenticating. Contact your administrator.",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
logger.info(f"Login successful. (U:{logged_in_uname} - IP:{ip})")
|
|
224
|
+
|
|
225
|
+
xsrf_token = generate_random_secret()
|
|
226
|
+
|
|
227
|
+
# Generate the token this user can use to authenticate from now on
|
|
228
|
+
|
|
229
|
+
if access_token:
|
|
230
|
+
app_token = access_token
|
|
231
|
+
else:
|
|
232
|
+
app_token = f"{logged_in_uname}:{auth_service.create_token(logged_in_uname, typing.cast(list[str], priv))}"
|
|
233
|
+
|
|
234
|
+
return ok(
|
|
235
|
+
{
|
|
236
|
+
"app_token": app_token,
|
|
237
|
+
"provider": oauth_provider,
|
|
238
|
+
"refresh_token": refresh_token,
|
|
239
|
+
"privileges": priv,
|
|
240
|
+
"user": cur_user,
|
|
241
|
+
},
|
|
242
|
+
cookies={"XSRF-TOKEN": xsrf_token},
|
|
243
|
+
)
|
clue/api/v1/configs.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import clue.services.config_service as config_service
|
|
2
|
+
from clue.api import make_subapi_blueprint, not_found, ok
|
|
3
|
+
from clue.common.swagger import generate_swagger_docs
|
|
4
|
+
from clue.models.network import QueryResult
|
|
5
|
+
|
|
6
|
+
SUB_API = "configs"
|
|
7
|
+
configs_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
8
|
+
configs_api._doc = "Read configuration data about the system"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@generate_swagger_docs()
|
|
12
|
+
@configs_api.route("/", methods=["GET"])
|
|
13
|
+
def configs(**kwargs):
|
|
14
|
+
"""Return all of the configuration information about the deployment.
|
|
15
|
+
|
|
16
|
+
Variables:
|
|
17
|
+
None
|
|
18
|
+
|
|
19
|
+
Arguments:
|
|
20
|
+
None
|
|
21
|
+
|
|
22
|
+
Result Example:
|
|
23
|
+
{
|
|
24
|
+
"configuration": { # Configuration block
|
|
25
|
+
"auth": { # Authentication block
|
|
26
|
+
"oauth_providers": [ # List of oAuth providers available
|
|
27
|
+
"azure_ad",
|
|
28
|
+
"keyclock",
|
|
29
|
+
...
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
"system": { # System Configuration
|
|
33
|
+
"branch": "develop", # Branch the current deployment is connected to
|
|
34
|
+
"commit": "123456789abcdef", # Last commit ID
|
|
35
|
+
"version": "1.0" # Clue version
|
|
36
|
+
},
|
|
37
|
+
"ui": { # UI Configuration
|
|
38
|
+
"apps": [], # List of apps shown in the apps switcher
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"c12nDef": {}, # Classification definition block
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
return ok(config_service.get_configuration())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@configs_api.route("/schema/<model>", methods=["GET"])
|
|
49
|
+
def schemas(model: str, **kwargs):
|
|
50
|
+
"""Return a JSON schema for a given model.
|
|
51
|
+
|
|
52
|
+
Variables:
|
|
53
|
+
model => The model for which to return the schema. Valid options: plugin_response
|
|
54
|
+
|
|
55
|
+
Arguments:
|
|
56
|
+
None
|
|
57
|
+
|
|
58
|
+
Result Example:
|
|
59
|
+
{
|
|
60
|
+
"properties": {
|
|
61
|
+
"error": {
|
|
62
|
+
"anyOf": [
|
|
63
|
+
{
|
|
64
|
+
"type": "string"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"type": "null"
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"default": null,
|
|
71
|
+
"description": "Error message returned by data source",
|
|
72
|
+
"title": "Error"
|
|
73
|
+
},
|
|
74
|
+
...
|
|
75
|
+
},
|
|
76
|
+
"title": "QueryResult",
|
|
77
|
+
"type": "object"
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
if model == "plugin_response":
|
|
81
|
+
return ok(QueryResult.model_json_schema())
|
|
82
|
+
|
|
83
|
+
return not_found(err="Not a valid model")
|
clue/api/v1/fetchers.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Enrichment Fetchers
|
|
2
|
+
|
|
3
|
+
List and execute fetchers that provide data to be rendered client-side
|
|
4
|
+
|
|
5
|
+
* Provides endpoints to list valid fetchers exposed by plugins.
|
|
6
|
+
* Provides endpoints to run these fetchers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from flask_cors import CORS
|
|
10
|
+
|
|
11
|
+
from clue.api import bad_gateway, bad_request, make_subapi_blueprint, not_found, ok
|
|
12
|
+
from clue.common.exceptions import ClueException, NotFoundException
|
|
13
|
+
from clue.common.logging import get_logger
|
|
14
|
+
from clue.common.swagger import generate_swagger_docs
|
|
15
|
+
from clue.config import config
|
|
16
|
+
from clue.models.fetchers import FetcherDefinition
|
|
17
|
+
from clue.security import api_login
|
|
18
|
+
from clue.services import fetcher_service
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__file__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SUB_API = "fetchers"
|
|
24
|
+
fetchers_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
25
|
+
fetchers_api._doc = "Run fetchers for a given ID through configured external data sources/systems."
|
|
26
|
+
|
|
27
|
+
CORS(fetchers_api, origins=config.ui.cors_origins, supports_credentials=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@generate_swagger_docs(responses={200: "A list of types and their classification"})
|
|
31
|
+
@fetchers_api.route("/", methods=["GET"])
|
|
32
|
+
@api_login()
|
|
33
|
+
def get_fetchers(**kwargs) -> dict[str, FetcherDefinition]:
|
|
34
|
+
"""Return the supported fetchers of each external service.
|
|
35
|
+
|
|
36
|
+
Variables:
|
|
37
|
+
None
|
|
38
|
+
|
|
39
|
+
Arguments:
|
|
40
|
+
None
|
|
41
|
+
|
|
42
|
+
Result Example:
|
|
43
|
+
{ # A dictionary of sources with their supported fetchers.
|
|
44
|
+
<source_id>.<fetcher_id>: {
|
|
45
|
+
"id": "<fetcher_id>",
|
|
46
|
+
"classification": "",
|
|
47
|
+
"description": "",
|
|
48
|
+
"format": ""
|
|
49
|
+
"supported_types": ["ip", ...]
|
|
50
|
+
},
|
|
51
|
+
...,
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
return ok(fetcher_service.get_plugins_supported_fetchers(kwargs["user"]))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@generate_swagger_docs(responses={200: "Successful lookup to selected plugins"})
|
|
58
|
+
@fetchers_api.route("/<plugin_id>/<fetcher_id>", methods=["POST"])
|
|
59
|
+
@api_login()
|
|
60
|
+
def run_fetcher(plugin_id: str, fetcher_id: str, **kwargs):
|
|
61
|
+
"""Search other services for additional information related to the provided data.
|
|
62
|
+
|
|
63
|
+
Variables:
|
|
64
|
+
plugin_id (str): the ID of the plugin who owns the action to execute
|
|
65
|
+
fetcher_id (str): the ID of the action to execute
|
|
66
|
+
|
|
67
|
+
Arguments:
|
|
68
|
+
None
|
|
69
|
+
|
|
70
|
+
Data Block:
|
|
71
|
+
{
|
|
72
|
+
type: "ip",
|
|
73
|
+
value: "127.0.0.1",
|
|
74
|
+
...
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Result Example:
|
|
78
|
+
{
|
|
79
|
+
"outcome": "success | failure", # was this execution a success or failure?
|
|
80
|
+
"format": "link", # What format is the output in?
|
|
81
|
+
"output": "http://example.com" # The output of the action. Can be any data structure.
|
|
82
|
+
}
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
return ok(fetcher_service.run_fetcher(plugin_id, fetcher_id, kwargs["user"]))
|
|
86
|
+
except NotFoundException as err:
|
|
87
|
+
return not_found(err=err.message)
|
|
88
|
+
except ClueException as err:
|
|
89
|
+
if err.status_code == 400:
|
|
90
|
+
logger.warning("Bad request from fetcher %s.%s: %s", plugin_id, fetcher_id, err.message)
|
|
91
|
+
return bad_request(err=err.message)
|
|
92
|
+
|
|
93
|
+
logger.warning("Unknown error from fetcher %s.%s: %s", plugin_id, fetcher_id, err.message)
|
|
94
|
+
return bad_gateway(err=err.message)
|
clue/api/v1/lookup.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Enrichment Lookup
|
|
2
|
+
|
|
3
|
+
Lookup related data from external systems.
|
|
4
|
+
|
|
5
|
+
* Provide endpoints to list accepted types of data.
|
|
6
|
+
* Provide endpoints to query other systems to enable enrichment of such types.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.parse
|
|
11
|
+
|
|
12
|
+
from flask import request
|
|
13
|
+
from flask_cors import CORS
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from clue.api import bad_request, make_subapi_blueprint, ok, unauthorized
|
|
17
|
+
from clue.common.exceptions import AuthenticationException, InvalidDataException
|
|
18
|
+
from clue.common.logging import get_logger
|
|
19
|
+
from clue.common.swagger import generate_swagger_docs
|
|
20
|
+
from clue.config import config
|
|
21
|
+
from clue.models.network import QueryResult
|
|
22
|
+
from clue.models.selector import Selector
|
|
23
|
+
from clue.security import api_login
|
|
24
|
+
from clue.services import lookup_service, type_service
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__file__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SUB_API = "lookup"
|
|
30
|
+
lookup_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
31
|
+
lookup_api._doc = "Lookup related data through configured external data sources/systems."
|
|
32
|
+
|
|
33
|
+
CORS(lookup_api, origins=config.ui.cors_origins, supports_credentials=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@generate_swagger_docs(responses={200: "A list of types and their classification"})
|
|
37
|
+
@lookup_api.route("/types/", methods=["GET"])
|
|
38
|
+
@api_login()
|
|
39
|
+
def get_types(**kwargs) -> dict[str, list[str]]:
|
|
40
|
+
"""Return the supported types of each external service.
|
|
41
|
+
|
|
42
|
+
Variables:
|
|
43
|
+
None
|
|
44
|
+
|
|
45
|
+
Arguments:
|
|
46
|
+
None
|
|
47
|
+
|
|
48
|
+
Result Example:
|
|
49
|
+
{ # A dictionary of sources with their supported types.
|
|
50
|
+
<source_name>: [
|
|
51
|
+
<type name>,
|
|
52
|
+
<type name>,
|
|
53
|
+
...,
|
|
54
|
+
],
|
|
55
|
+
...,
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
return ok(type_service.get_plugins_supported_types(kwargs["user"]))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@generate_swagger_docs(responses={200: "A list of types and their regex detectors"})
|
|
62
|
+
@lookup_api.route("/types_detection/", methods=["GET"])
|
|
63
|
+
@api_login()
|
|
64
|
+
def get_types_detection(**kwargs) -> dict[str, str]:
|
|
65
|
+
"""Return the regular expression to detect the different types
|
|
66
|
+
|
|
67
|
+
Variables:
|
|
68
|
+
None
|
|
69
|
+
|
|
70
|
+
Arguments:
|
|
71
|
+
None
|
|
72
|
+
|
|
73
|
+
Result Example:
|
|
74
|
+
{ # A dictionary of types with their associated regular expressions
|
|
75
|
+
<type>: <regex>,
|
|
76
|
+
...
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
return ok(type_service.get_types_regular_expressions(kwargs["user"]))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@generate_swagger_docs(responses={200: "Successful bulk lookup to selected plugins for included values"})
|
|
83
|
+
@lookup_api.route("/enrich", methods=["POST"])
|
|
84
|
+
@api_login()
|
|
85
|
+
def bulk_enrich(**kwargs) -> dict[str, dict[str, dict[str, QueryResult]]]:
|
|
86
|
+
"""Search other services for additional information related to the provided data.
|
|
87
|
+
|
|
88
|
+
Variables:
|
|
89
|
+
None
|
|
90
|
+
|
|
91
|
+
Optional Arguments:
|
|
92
|
+
classification: string => Classification of the type [Default: minimum configured classification]
|
|
93
|
+
sources: string => | separated list of data sources. If empty, all configured sources are used.
|
|
94
|
+
max_timeout: number => Maximum execution time for the call in seconds
|
|
95
|
+
limit: number => limit the amount of returned results counted per source
|
|
96
|
+
no_annotation: boolean => Do not return any anotations
|
|
97
|
+
no_cache: boolean => Skip the cache and ask the plugins again
|
|
98
|
+
include_raw: boolean => Return raw plugin data
|
|
99
|
+
exclude_unset: boolean => Do not return any values that were not set by the plugin
|
|
100
|
+
|
|
101
|
+
Data Block:
|
|
102
|
+
[
|
|
103
|
+
{"type": "ip", "value": "127.0.0.1"},
|
|
104
|
+
...
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
Result Example:
|
|
108
|
+
{ # Dictionary of data source queried
|
|
109
|
+
"ip": {
|
|
110
|
+
"127.0.0.1":{
|
|
111
|
+
"vt": {
|
|
112
|
+
"error": null, # Error message returned by data source
|
|
113
|
+
"items": [ # list of results from the source
|
|
114
|
+
{
|
|
115
|
+
"link": "https://www.virustotal.com/gui/url/<id>", # link to results
|
|
116
|
+
"count": 1, # number of hits from the search
|
|
117
|
+
"classification": "TLP:C", # classification of the search result
|
|
118
|
+
"annotations": [ # Semi structured details about data
|
|
119
|
+
<Annotation data>
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
...,
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
...,
|
|
126
|
+
},
|
|
127
|
+
...
|
|
128
|
+
},
|
|
129
|
+
...
|
|
130
|
+
}
|
|
131
|
+
"""
|
|
132
|
+
user = kwargs["user"]
|
|
133
|
+
|
|
134
|
+
post_data = request.json
|
|
135
|
+
|
|
136
|
+
if not isinstance(post_data, list):
|
|
137
|
+
return bad_request(err="Request data is not in the correct format")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
data = [Selector.model_validate(entry) for entry in post_data]
|
|
141
|
+
except ValidationError as err:
|
|
142
|
+
pydantic_errs: list[str] = []
|
|
143
|
+
|
|
144
|
+
for validation_err in err.errors():
|
|
145
|
+
loc = ".".join(
|
|
146
|
+
section if isinstance(section, str) else f"[{str(section)}]" for section in validation_err["loc"]
|
|
147
|
+
)
|
|
148
|
+
pydantic_errs.append(f'"{loc}": {validation_err["msg"]}')
|
|
149
|
+
|
|
150
|
+
return bad_request(err=f"Request data is not in the correct format: {', '.join(pydantic_errs)}")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
results = lookup_service.bulk_enrich(data, user)
|
|
154
|
+
except AuthenticationException as e:
|
|
155
|
+
return unauthorized(err=str(e))
|
|
156
|
+
except InvalidDataException as e:
|
|
157
|
+
return bad_request(err=str(e))
|
|
158
|
+
|
|
159
|
+
return ok(results)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@generate_swagger_docs(responses={200: "Successful lookup to selected plugins"})
|
|
163
|
+
@lookup_api.route("/enrich/<type_name>/<value>/", methods=["GET"])
|
|
164
|
+
@api_login()
|
|
165
|
+
def enrich(type_name: str, value: str, **kwargs) -> dict[str, QueryResult]:
|
|
166
|
+
"""Search other services for additional information related to the provided data.
|
|
167
|
+
|
|
168
|
+
Variables:
|
|
169
|
+
type_name => Type of data to lookup in the external system.
|
|
170
|
+
value => Value of the data to lookup. *Must be double URL encoded.*
|
|
171
|
+
|
|
172
|
+
Optional Arguments:
|
|
173
|
+
classification: string => Classification of the type [Default: minimum configured classification]
|
|
174
|
+
sources: string => | separated list of data sources. If empty, all configured sources are used.
|
|
175
|
+
max_timeout: number => Maximum execution time for the call in seconds
|
|
176
|
+
limit: number => limit the amount of returned results counted per source
|
|
177
|
+
no_annotation: boolean => Do not return any anotations
|
|
178
|
+
no_cache: boolean => Skip the cache and ask the plugins again
|
|
179
|
+
include_raw: boolean => Return raw plugin data
|
|
180
|
+
exclude_unset: boolean => Do not return any values that were not set by the plugin
|
|
181
|
+
|
|
182
|
+
API Call Examples:
|
|
183
|
+
/api/v1/lookup/enrich/domain/malicious.domain/
|
|
184
|
+
/api/v1/lookup/enrich/ip/1.1.1.1/?sources=vt|malware_bazar
|
|
185
|
+
|
|
186
|
+
Result Example:
|
|
187
|
+
{ # Dictionary of data source queried
|
|
188
|
+
"vt": {
|
|
189
|
+
"error": null, # Error message returned by data source
|
|
190
|
+
"items": [ # list of results from the source
|
|
191
|
+
{
|
|
192
|
+
"link": "https://www.virustotal.com/gui/url/<id>", # link to results
|
|
193
|
+
"count": 1, # number of hits from the search
|
|
194
|
+
"classification": "TLP:C", # classification of the search result
|
|
195
|
+
"annotations": [ # Semi structured details about type of data
|
|
196
|
+
<Annotation data>
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
...,
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
...,
|
|
203
|
+
}
|
|
204
|
+
"""
|
|
205
|
+
user = kwargs["user"]
|
|
206
|
+
|
|
207
|
+
# For backwards compatability, if eml is used it is replaced with email
|
|
208
|
+
type_name = type_name.replace("eml", "email")
|
|
209
|
+
|
|
210
|
+
if type_name == "telemetry":
|
|
211
|
+
try:
|
|
212
|
+
json.loads(urllib.parse.unquote(value))
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
return bad_request(err="If type is telemetry, value must be a valid JSON object.")
|
|
215
|
+
|
|
216
|
+
# re-encode the type after being decoded going through flask/wsgi route
|
|
217
|
+
value = urllib.parse.quote(value, safe="")
|
|
218
|
+
|
|
219
|
+
results = lookup_service.enrich(type_name, value, user)
|
|
220
|
+
|
|
221
|
+
return ok(results)
|