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.
Files changed (91) hide show
  1. clue/.gitignore +21 -0
  2. clue/__init__.py +0 -0
  3. clue/api/__init__.py +211 -0
  4. clue/api/base.py +99 -0
  5. clue/api/v1/__init__.py +82 -0
  6. clue/api/v1/actions.py +92 -0
  7. clue/api/v1/auth.py +243 -0
  8. clue/api/v1/configs.py +83 -0
  9. clue/api/v1/fetchers.py +94 -0
  10. clue/api/v1/lookup.py +221 -0
  11. clue/api/v1/registration.py +109 -0
  12. clue/api/v1/static.py +94 -0
  13. clue/app.py +166 -0
  14. clue/cache/__init__.py +129 -0
  15. clue/common/__init__.py +0 -0
  16. clue/common/classification.py +1006 -0
  17. clue/common/classification.yml +130 -0
  18. clue/common/dict_utils.py +130 -0
  19. clue/common/exceptions.py +199 -0
  20. clue/common/forge.py +152 -0
  21. clue/common/json_utils.py +10 -0
  22. clue/common/list_utils.py +11 -0
  23. clue/common/logging/__init__.py +291 -0
  24. clue/common/logging/audit.py +157 -0
  25. clue/common/logging/format.py +42 -0
  26. clue/common/regex.py +31 -0
  27. clue/common/str_utils.py +213 -0
  28. clue/common/swagger.py +139 -0
  29. clue/common/uid.py +47 -0
  30. clue/config.py +60 -0
  31. clue/constants/__init__.py +0 -0
  32. clue/constants/supported_types.py +38 -0
  33. clue/cronjobs/__init__.py +30 -0
  34. clue/cronjobs/plugins.py +32 -0
  35. clue/error.py +129 -0
  36. clue/gunicorn_config.py +29 -0
  37. clue/healthz.py +74 -0
  38. clue/helper/discover.py +53 -0
  39. clue/helper/headers.py +30 -0
  40. clue/helper/oauth.py +128 -0
  41. clue/models/__init__.py +0 -0
  42. clue/models/actions.py +243 -0
  43. clue/models/config.py +456 -0
  44. clue/models/fetchers.py +136 -0
  45. clue/models/graph.py +162 -0
  46. clue/models/model_list.py +52 -0
  47. clue/models/network.py +430 -0
  48. clue/models/results/__init__.py +34 -0
  49. clue/models/results/base.py +10 -0
  50. clue/models/results/graph.py +26 -0
  51. clue/models/results/image.py +22 -0
  52. clue/models/results/status.py +55 -0
  53. clue/models/results/validation.py +57 -0
  54. clue/models/selector.py +67 -0
  55. clue/models/utils.py +52 -0
  56. clue/models/validators.py +19 -0
  57. clue/patched.py +8 -0
  58. clue/plugin/__init__.py +1008 -0
  59. clue/plugin/helpers/__init__.py +0 -0
  60. clue/plugin/helpers/central_server.py +27 -0
  61. clue/plugin/helpers/email_render.py +228 -0
  62. clue/plugin/helpers/token.py +34 -0
  63. clue/plugin/helpers/trino.py +103 -0
  64. clue/plugin/interactive.py +270 -0
  65. clue/plugin/models.py +19 -0
  66. clue/plugin/utils.py +78 -0
  67. clue/remote/__init__.py +0 -0
  68. clue/remote/datatypes/__init__.py +130 -0
  69. clue/remote/datatypes/cache.py +62 -0
  70. clue/remote/datatypes/events.py +118 -0
  71. clue/remote/datatypes/hash.py +193 -0
  72. clue/remote/datatypes/queues/__init__.py +0 -0
  73. clue/remote/datatypes/queues/comms.py +62 -0
  74. clue/remote/datatypes/set.py +96 -0
  75. clue/remote/datatypes/user_quota_tracker.py +54 -0
  76. clue/security/__init__.py +211 -0
  77. clue/security/obo.py +95 -0
  78. clue/security/utils.py +34 -0
  79. clue/services/action_service.py +186 -0
  80. clue/services/auth_service.py +348 -0
  81. clue/services/config_service.py +38 -0
  82. clue/services/fetcher_service.py +203 -0
  83. clue/services/jwt_service.py +233 -0
  84. clue/services/lookup_service.py +786 -0
  85. clue/services/type_service.py +165 -0
  86. clue/services/user_service.py +152 -0
  87. clue_api-1.0.0.dev7.dist-info/METADATA +111 -0
  88. clue_api-1.0.0.dev7.dist-info/RECORD +91 -0
  89. clue_api-1.0.0.dev7.dist-info/WHEEL +4 -0
  90. clue_api-1.0.0.dev7.dist-info/entry_points.txt +8 -0
  91. 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")
@@ -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)