clue-api 1.6.0.dev323__tar.gz → 1.6.0.dev337__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.
Files changed (99) hide show
  1. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/PKG-INFO +1 -1
  2. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/lookup.py +8 -2
  3. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/services/fetcher_service.py +56 -35
  4. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/services/lookup_service.py +68 -6
  5. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/pyproject.toml +1 -1
  6. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/LICENSE +0 -0
  7. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/README.md +0 -0
  8. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/.gitignore +0 -0
  9. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/__init__.py +0 -0
  10. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/__init__.py +0 -0
  11. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/base.py +0 -0
  12. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/__init__.py +0 -0
  13. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/actions.py +0 -0
  14. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/auth.py +0 -0
  15. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/configs.py +0 -0
  16. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/fetchers.py +0 -0
  17. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/registration.py +0 -0
  18. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/static.py +0 -0
  19. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/api/v1/sync.py +0 -0
  20. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/app.py +0 -0
  21. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/cache/__init__.py +0 -0
  22. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/__init__.py +0 -0
  23. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/bytes_utils.py +0 -0
  24. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/classification.py +0 -0
  25. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/classification.yml +0 -0
  26. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/dict_utils.py +0 -0
  27. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/exceptions.py +0 -0
  28. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/forge.py +0 -0
  29. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/json_utils.py +0 -0
  30. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/list_utils.py +0 -0
  31. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/logging/__init__.py +0 -0
  32. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/logging/audit.py +0 -0
  33. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/logging/format.py +0 -0
  34. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/regex.py +0 -0
  35. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/str_utils.py +0 -0
  36. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/swagger.py +0 -0
  37. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/common/uid.py +0 -0
  38. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/config.py +0 -0
  39. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/constants/__init__.py +0 -0
  40. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/constants/env.py +0 -0
  41. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/constants/supported_types.py +0 -0
  42. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/cronjobs/__init__.py +0 -0
  43. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/cronjobs/plugins.py +0 -0
  44. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/error.py +0 -0
  45. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/extensions/__init__.py +0 -0
  46. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/extensions/config.py +0 -0
  47. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/gunicorn_config.py +0 -0
  48. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/healthz.py +0 -0
  49. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/helper/discover.py +0 -0
  50. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/helper/headers.py +0 -0
  51. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/helper/oauth.py +0 -0
  52. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/__init__.py +0 -0
  53. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/actions.py +0 -0
  54. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/config.py +0 -0
  55. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/fetchers.py +0 -0
  56. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/graph.py +0 -0
  57. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/model_list.py +0 -0
  58. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/network.py +0 -0
  59. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/results/__init__.py +0 -0
  60. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/results/base.py +0 -0
  61. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/results/file.py +0 -0
  62. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/results/graph.py +0 -0
  63. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/results/image.py +0 -0
  64. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/results/status.py +0 -0
  65. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/results/validation.py +0 -0
  66. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/schema.py +0 -0
  67. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/selector.py +0 -0
  68. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/sync.py +0 -0
  69. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/models/validators.py +0 -0
  70. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/patched.py +0 -0
  71. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/__init__.py +0 -0
  72. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/celery_app.py +0 -0
  73. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/helpers/__init__.py +0 -0
  74. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/helpers/central_server.py +0 -0
  75. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/helpers/email_render.py +0 -0
  76. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/helpers/token.py +0 -0
  77. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/helpers/trino.py +0 -0
  78. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/models.py +0 -0
  79. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/plugin/utils.py +0 -0
  80. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/py.typed +0 -0
  81. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/__init__.py +0 -0
  82. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/datatypes/__init__.py +0 -0
  83. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/datatypes/cache.py +0 -0
  84. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/datatypes/events.py +0 -0
  85. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/datatypes/hash.py +0 -0
  86. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/datatypes/queues/__init__.py +0 -0
  87. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/datatypes/queues/comms.py +0 -0
  88. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/datatypes/set.py +0 -0
  89. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/remote/datatypes/user_quota_tracker.py +0 -0
  90. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/security/__init__.py +0 -0
  91. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/security/obo.py +0 -0
  92. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/security/utils.py +0 -0
  93. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/services/action_service.py +0 -0
  94. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/services/auth_service.py +0 -0
  95. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/services/config_service.py +0 -0
  96. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/services/jwt_service.py +0 -0
  97. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/services/mongo_service.py +0 -0
  98. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/clue/services/type_service.py +0 -0
  99. {clue_api-1.6.0.dev323 → clue_api-1.6.0.dev337}/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.6.0.dev323
3
+ Version: 1.6.0.dev337
4
4
  Summary: Clue distributed enrichment service
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -90,7 +90,10 @@ def bulk_enrich(**kwargs) -> dict[str, dict[str, dict[str, QueryResult]]]:
90
90
 
91
91
  Optional Arguments:
92
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.
93
+ sources: string => | separated list of data sources.
94
+ A source prefixed with '-' will be excluded. Exclusion takes precedence over inclusion.
95
+ If sources is empty or only exclusions, all default configured sources are used (with exclusions applied).
96
+ Note, a source list that includes and excludes the same sources (e.g. sources=vt|-vt) is not treated as empty.
94
97
  max_timeout: number => Maximum execution time for the call in seconds
95
98
  limit: number => limit the amount of returned results counted per source
96
99
  no_annotation: boolean => Do not return any anotations
@@ -171,7 +174,10 @@ def enrich(type_name: str, value: str, **kwargs) -> dict[str, QueryResult]:
171
174
 
172
175
  Optional Arguments:
173
176
  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.
177
+ sources: string => | separated list of data sources.
178
+ A source prefixed with '-' will be excluded. Exclusion takes precedence over inclusion.
179
+ If sources is empty or only exclusions, all default configured sources are used (with exclusions applied).
180
+ Note, a source list that includes and excludes the same sources (e.g. sources=vt|-vt) is not treated as empty.
175
181
  max_timeout: number => Maximum execution time for the call in seconds
176
182
  limit: number => limit the amount of returned results counted per source
177
183
  no_annotation: boolean => Do not return any anotations
@@ -3,7 +3,7 @@ from urllib.parse import urljoin
3
3
 
4
4
  import elasticapm
5
5
  import requests
6
- from flask import request
6
+ from flask import has_request_context, request
7
7
  from pydantic import TypeAdapter, ValidationError
8
8
  from requests import JSONDecodeError, exceptions
9
9
 
@@ -11,6 +11,7 @@ from clue.common.exceptions import (
11
11
  AuthenticationException,
12
12
  ClueException,
13
13
  ClueValueError,
14
+ InvalidDataException,
14
15
  NotFoundException,
15
16
  )
16
17
  from clue.common.logging import get_logger
@@ -26,6 +27,26 @@ logger = get_logger(__file__)
26
27
  CACHE_TIMEOUT: int = 1 if DEBUG else 5 * 60
27
28
 
28
29
 
30
+ def get_obo_access_token(
31
+ source: ExternalSource, user: dict[str, Any], access_token: Optional[str] = None
32
+ ) -> tuple[Optional[str], Optional[str]]:
33
+ """Get the caller access token and an OBO token for an external source when needed."""
34
+ if access_token is None and has_request_context():
35
+ auth_header = request.headers.get("Authorization", type=str)
36
+ if auth_header:
37
+ parts = auth_header.split(" ", 1)
38
+ access_token = parts[1] if len(parts) == 2 and parts[0].lower() == "bearer" else auth_header
39
+ if not access_token:
40
+ return None, None
41
+
42
+ obo_access_token, error = auth_service.check_obo(source, access_token, user["uname"])
43
+ if error:
44
+ logger.error("%s: %s", source.name, error)
45
+ raise AuthenticationException("Invalid token provided for this enrichment.")
46
+
47
+ return access_token, obo_access_token
48
+
49
+
29
50
  @cache.memoize(timeout=1 if DEBUG else 5 * 60, args_to_ignore=["access_token"]) # Cached for 5 minutes
30
51
  def get_supported_fetchers(
31
52
  source: ExternalSource, user: dict[str, Any], access_token: Optional[str] = None
@@ -43,13 +64,10 @@ def get_supported_fetchers(
43
64
 
44
65
  url = urljoin(source.url, "fetchers/")
45
66
 
46
- obo_access_token = None
47
- if access_token:
48
- obo_access_token, error = auth_service.check_obo(source, access_token, user["uname"])
49
-
50
- if error:
51
- logger.error("%s: %s", source.name, error)
52
- return {}
67
+ try:
68
+ access_token, obo_access_token = get_obo_access_token(source, user, access_token)
69
+ except AuthenticationException:
70
+ return {}
53
71
 
54
72
  headers = {"Accept": "application/json"}
55
73
  if obo_access_token or access_token:
@@ -130,6 +148,15 @@ def get_plugins_supported_fetchers(user: dict[str, Any]) -> dict[str, FetcherDef
130
148
  return available_fetchers
131
149
 
132
150
 
151
+ def _validate_fetcher_classification(fetcher: FetcherDefinition | None, selector: Selector, fetcher_id: str) -> None:
152
+ if fetcher and not CLASSIFICATION.is_accessible(fetcher.classification, selector.classification):
153
+ raise InvalidDataException(
154
+ f"Cannot send data classified as {selector.classification} to fetcher {fetcher_id} "
155
+ f"at classification {fetcher.classification}.",
156
+ status_code=400,
157
+ )
158
+
159
+
133
160
  def run_fetcher(plugin_id: str, fetcher_id: str, user: dict[str, Any]) -> FetcherResult:
134
161
  """Executes a specified fetcher.
135
162
 
@@ -150,30 +177,34 @@ def run_fetcher(plugin_id: str, fetcher_id: str, user: dict[str, Any]) -> Fetche
150
177
  if not plugin:
151
178
  raise NotFoundException(f"Plugin {plugin_id} does not exist.")
152
179
 
153
- access_token = request.headers.get("Authorization", type=str)
154
- if access_token:
155
- access_token = access_token.split(" ")[1]
156
-
157
- obo_access_token = None
158
- if access_token:
159
- obo_access_token, error = auth_service.check_obo(plugin, access_token, user["uname"])
160
-
161
- if error:
162
- logger.error("%s: %s", plugin.name, error)
163
- raise AuthenticationException("Invalid token provided for this enrichment.")
180
+ access_token, obo_access_token = get_obo_access_token(plugin, user)
164
181
 
165
182
  headers = {"Accept": "application/json"}
166
183
  if obo_access_token or access_token:
167
184
  headers["Authorization"] = f"Bearer {obo_access_token or access_token}"
168
185
 
169
- if request.content_type == "application/json":
186
+ if request.is_json:
170
187
  parameters = request.json
171
188
  else:
172
- # TODO: Pass parameters via urlencode?
173
- parameters = {}
189
+ logger.error(
190
+ "Invalid content-type detected: %s",
191
+ )
192
+ raise ClueValueError(
193
+ "The request body must be of type application/json.",
194
+ status_code=400,
195
+ )
174
196
 
175
197
  try:
176
- Selector.model_validate(parameters)
198
+ selector = Selector.model_validate(parameters)
199
+ supported_fetchers = get_supported_fetchers(plugin, user, access_token=access_token)
200
+
201
+ if len(supported_fetchers) < 1:
202
+ raise NotFoundException(f"{plugin_id} does not support any fetchers.")
203
+
204
+ fetcher = supported_fetchers.get(fetcher_id)
205
+ if fetcher is None:
206
+ raise NotFoundException(f"Fetcher {fetcher_id} does not exist", status_code=404)
207
+ _validate_fetcher_classification(fetcher, selector, fetcher_id)
177
208
 
178
209
  response = requests.post(
179
210
  urljoin(plugin.url, f"fetchers/{fetcher_id}"),
@@ -192,7 +223,7 @@ def run_fetcher(plugin_id: str, fetcher_id: str, user: dict[str, Any]) -> Fetche
192
223
  return FetcherResult.model_validate(result["api_response"], context={"is_response": True})
193
224
  except ValidationError as err:
194
225
  logger.exception("Invalid Request Body:")
195
- raise ClueValueError(
226
+ raise InvalidDataException(
196
227
  "Validation error encountered on request body. Ensure your request body is properly formatted.",
197
228
  status_code=400,
198
229
  ) from err
@@ -224,17 +255,7 @@ def get_fetcher_status(plugin_id: str, fetcher_id: str, task_id: str, user: dict
224
255
  if not plugin:
225
256
  raise NotFoundException(f"Plugin {plugin_id} does not exist.")
226
257
 
227
- access_token = request.headers.get("Authorization", type=str)
228
- if access_token:
229
- access_token = access_token.split(" ")[1]
230
-
231
- obo_access_token = None
232
- if access_token:
233
- obo_access_token, error = auth_service.check_obo(plugin, access_token, user["uname"])
234
-
235
- if error:
236
- logger.error("%s: %s", plugin.name, error)
237
- raise AuthenticationException("Invalid token provided for this enrichment.")
258
+ access_token, obo_access_token = get_obo_access_token(plugin, user)
238
259
 
239
260
  headers = {"Accept": "application/json"}
240
261
  if obo_access_token or access_token:
@@ -107,6 +107,7 @@ class ParsedParams(BaseModel):
107
107
  "Validation of parameters parsed from request"
108
108
 
109
109
  query_sources: list[str]
110
+ excluded_sources: list[str]
110
111
  max_timeout: float
111
112
  limit: int
112
113
  type_classification: str
@@ -153,8 +154,11 @@ def parse_query_params(request: Request, limit: int = 10, timeout: float = 5.0):
153
154
  else:
154
155
  query_sources = []
155
156
 
157
+ include_sources, exclude_sources = _parse_source_list(query_sources)
158
+
156
159
  return ParsedParams(
157
- query_sources=query_sources,
160
+ query_sources=include_sources,
161
+ excluded_sources=exclude_sources,
158
162
  max_timeout=parse_timeout(timeout),
159
163
  limit=limit,
160
164
  type_classification=type_classification,
@@ -456,6 +460,7 @@ def enrich(type_name: str, value: str, user: dict[str, Any]): # noqa: C901
456
460
  """
457
461
  query_params = parse_query_params(request=request)
458
462
  query_sources = query_params.query_sources
463
+ excluded_sources = query_params.excluded_sources
459
464
  available_sources = get_sources(user)
460
465
 
461
466
  access_token = request.headers.get("Authorization", type=str)
@@ -465,7 +470,8 @@ def enrich(type_name: str, value: str, user: dict[str, Any]): # noqa: C901
465
470
 
466
471
  logger.debug(
467
472
  f"Beginning enrichment for single selector on sources "
468
- f"[{','.join(query_sources or [source.name for source in available_sources])}]"
473
+ f"[{','.join(query_sources or [source.name for source in available_sources if source.include_default])}] "
474
+ f"excluding sources [{','.join(excluded_sources)}]"
469
475
  )
470
476
 
471
477
  results: dict[str, QueryResult] = {}
@@ -478,7 +484,9 @@ def enrich(type_name: str, value: str, user: dict[str, Any]): # noqa: C901
478
484
  for source in available_sources:
479
485
  if query_sources and source.name not in query_sources:
480
486
  continue
481
- elif not query_sources and not source.include_default:
487
+ if excluded_sources and source.name in excluded_sources:
488
+ continue
489
+ if not query_sources and not source.include_default:
482
490
  continue
483
491
 
484
492
  finish_result = functools.partial(build_result, type_name, value, source)
@@ -668,11 +676,13 @@ def bulk_enrich(data: list[Selector], user: dict[str, Any]): # noqa: C901
668
676
  """create searches for external sources"""
669
677
  query_params = parse_query_params(request=request)
670
678
  query_sources = query_params.query_sources
679
+ excluded_sources = query_params.excluded_sources
671
680
  available_sources = get_sources(user)
672
681
 
673
682
  logger.debug(
674
683
  f"Beginning enrichment for {len(data)} selectors on sources "
675
- f"[{','.join(query_sources or [source.name for source in available_sources])}]"
684
+ f"[{','.join(query_sources or [source.name for source in available_sources if source.include_default])}] "
685
+ f"excluding sources [{','.join(excluded_sources)}]"
676
686
  )
677
687
 
678
688
  access_token = request.headers.get("Authorization", type=str)
@@ -695,11 +705,30 @@ def bulk_enrich(data: list[Selector], user: dict[str, Any]): # noqa: C901
695
705
  if config.ui.replication:
696
706
  existing_results = mongo_service.existing_results(user["uname"], "selectors", data, available_sources)
697
707
 
708
+ sources_per_entry: dict[tuple[str, str], tuple[list[str], list[str]]] = {}
709
+ for entry in data:
710
+ if entry.sources is not None:
711
+ entry_key = (entry.type, entry.value)
712
+ if entry_key not in sources_per_entry:
713
+ sources_per_entry[entry_key] = _parse_source_list(entry.sources)
714
+
715
+ else:
716
+ logger.warning("Duplicate sources found for entry %s:%s. Merging sources.", entry.type, entry.value)
717
+
718
+ existing_include_list, existing_exclude_list = sources_per_entry[entry_key]
719
+ include_sources, exclude_sources = _parse_source_list(entry.sources)
720
+ sources_per_entry[entry_key] = (
721
+ list(set(existing_include_list + include_sources)),
722
+ list(set(existing_exclude_list + exclude_sources)),
723
+ )
724
+
698
725
  greenlets: list[tuple[list[Selector], ExternalSource, Greenlet[Any, dict[str, dict[str, QueryResult]]]]] = []
699
726
  for source in available_sources:
700
727
  if query_sources and source.name not in query_sources:
701
728
  continue
702
- elif not query_sources and not source.include_default:
729
+ if excluded_sources and source.name in excluded_sources:
730
+ continue
731
+ if not query_sources and not source.include_default:
703
732
  continue
704
733
 
705
734
  obo_access_token, error = auth_service.check_obo(source, access_token, user["uname"])
@@ -720,9 +749,15 @@ def bulk_enrich(data: list[Selector], user: dict[str, Any]): # noqa: C901
720
749
  # check query against the max supported classification of the external system
721
750
  # if this is not supported, we should let the user know.
722
751
  for entry in data:
723
- if entry.sources is not None and source.name not in entry.sources:
752
+ if entry.sources == []: # support old behaviour of empty entry sources -> use no sources
724
753
  continue
725
754
 
755
+ parsed_sources = sources_per_entry.get((entry.type, entry.value))
756
+ if parsed_sources is not None:
757
+ include_sources, exclude_sources = parsed_sources
758
+ if (include_sources and source.name not in include_sources) or source.name in exclude_sources:
759
+ continue
760
+
726
761
  if (
727
762
  source.name in existing_results
728
763
  and {"type": entry.type, "value": entry.value} in existing_results[source.name]
@@ -802,3 +837,30 @@ def bulk_enrich(data: list[Selector], user: dict[str, Any]): # noqa: C901
802
837
  thread_pool.kill(block=False)
803
838
 
804
839
  return bulk_result
840
+
841
+
842
+ def _parse_source_list(source_list: list[str]) -> tuple[list[str], list[str]]:
843
+ """Splits the provided source list into include and exclude lists.
844
+
845
+ Args:
846
+ source_list (list[str]): The list of sources to parse.
847
+
848
+ Returns:
849
+ tuple[list[str], list[str]]: (include_sources, exclude_sources)
850
+ The list of sources to include and the list of sources to exclude.
851
+ """
852
+ include_sources = []
853
+ exclude_sources = []
854
+
855
+ for source in source_list:
856
+ source = source.strip()
857
+ if not source:
858
+ continue
859
+ if source.startswith("-"):
860
+ excluded_source = source[1:].strip()
861
+ if excluded_source:
862
+ exclude_sources.append(excluded_source)
863
+ else:
864
+ include_sources.append(source)
865
+
866
+ return include_sources, exclude_sources
@@ -141,7 +141,7 @@ log_cli_level = "WARN"
141
141
  [tool.poetry]
142
142
  package-mode = true
143
143
  name = "clue-api"
144
- version = "1.6.0.dev323"
144
+ version = "1.6.0.dev337"
145
145
  description = "Clue distributed enrichment service"
146
146
  authors = ["Canadian Centre for Cyber Security <contact@cyber.gc.ca>"]
147
147
  license = "MIT"
File without changes