clue-api 1.0.1.dev53__tar.gz → 1.0.1.dev58__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 (92) hide show
  1. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/PKG-INFO +1 -1
  2. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/__init__.py +319 -43
  3. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/interactive.py +6 -3
  4. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/pyproject.toml +3 -21
  5. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/LICENSE +0 -0
  6. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/README.md +0 -0
  7. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/.gitignore +0 -0
  8. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/__init__.py +0 -0
  9. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/__init__.py +0 -0
  10. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/base.py +0 -0
  11. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/v1/__init__.py +0 -0
  12. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/v1/actions.py +0 -0
  13. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/v1/auth.py +0 -0
  14. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/v1/configs.py +0 -0
  15. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/v1/fetchers.py +0 -0
  16. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/v1/lookup.py +0 -0
  17. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/v1/registration.py +0 -0
  18. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/api/v1/static.py +0 -0
  19. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/app.py +0 -0
  20. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/cache/__init__.py +0 -0
  21. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/__init__.py +0 -0
  22. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/classification.py +0 -0
  23. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/classification.yml +0 -0
  24. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/dict_utils.py +0 -0
  25. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/exceptions.py +0 -0
  26. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/forge.py +0 -0
  27. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/json_utils.py +0 -0
  28. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/list_utils.py +0 -0
  29. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/logging/__init__.py +0 -0
  30. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/logging/audit.py +0 -0
  31. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/logging/format.py +0 -0
  32. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/regex.py +0 -0
  33. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/str_utils.py +0 -0
  34. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/swagger.py +0 -0
  35. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/common/uid.py +0 -0
  36. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/config.py +0 -0
  37. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/constants/__init__.py +0 -0
  38. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/constants/supported_types.py +0 -0
  39. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/cronjobs/__init__.py +0 -0
  40. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/cronjobs/plugins.py +0 -0
  41. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/error.py +0 -0
  42. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/extensions/__init__.py +0 -0
  43. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/extensions/config.py +0 -0
  44. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/gunicorn_config.py +0 -0
  45. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/healthz.py +0 -0
  46. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/helper/discover.py +0 -0
  47. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/helper/headers.py +0 -0
  48. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/helper/oauth.py +0 -0
  49. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/__init__.py +0 -0
  50. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/actions.py +0 -0
  51. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/config.py +0 -0
  52. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/fetchers.py +0 -0
  53. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/graph.py +0 -0
  54. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/model_list.py +0 -0
  55. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/network.py +0 -0
  56. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/results/__init__.py +0 -0
  57. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/results/base.py +0 -0
  58. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/results/graph.py +0 -0
  59. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/results/image.py +0 -0
  60. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/results/status.py +0 -0
  61. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/results/validation.py +0 -0
  62. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/selector.py +0 -0
  63. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/models/validators.py +0 -0
  64. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/patched.py +0 -0
  65. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/helpers/__init__.py +0 -0
  66. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/helpers/central_server.py +0 -0
  67. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/helpers/email_render.py +0 -0
  68. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/helpers/token.py +0 -0
  69. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/helpers/trino.py +0 -0
  70. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/models.py +0 -0
  71. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/plugin/utils.py +0 -0
  72. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/py.typed +0 -0
  73. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/__init__.py +0 -0
  74. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/datatypes/__init__.py +0 -0
  75. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/datatypes/cache.py +0 -0
  76. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/datatypes/events.py +0 -0
  77. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/datatypes/hash.py +0 -0
  78. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/datatypes/queues/__init__.py +0 -0
  79. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/datatypes/queues/comms.py +0 -0
  80. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/datatypes/set.py +0 -0
  81. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/remote/datatypes/user_quota_tracker.py +0 -0
  82. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/security/__init__.py +0 -0
  83. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/security/obo.py +0 -0
  84. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/security/utils.py +0 -0
  85. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/services/action_service.py +0 -0
  86. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/services/auth_service.py +0 -0
  87. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/services/config_service.py +0 -0
  88. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/services/fetcher_service.py +0 -0
  89. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/services/jwt_service.py +0 -0
  90. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/services/lookup_service.py +0 -0
  91. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/clue/services/type_service.py +0 -0
  92. {clue_api-1.0.1.dev53 → clue_api-1.0.1.dev58}/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.0.1.dev53
3
+ Version: 1.0.1.dev58
4
4
  Summary: Clue distributed enrichment service
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,3 +1,4 @@
1
+ import inspect
1
2
  import ipaddress
2
3
  import json
3
4
  import logging
@@ -8,6 +9,7 @@ from urllib import parse as ul
8
9
 
9
10
  import gevent
10
11
  import gevent.pool
12
+ from dotenv import load_dotenv
11
13
  from flask import Flask, Response, jsonify, make_response, request
12
14
  from flask.globals import _cv_request
13
15
  from gevent import Greenlet
@@ -17,6 +19,7 @@ from clue.cache import Cache
17
19
  from clue.common.exceptions import (
18
20
  AuthenticationException,
19
21
  ClueException,
22
+ ClueValueError,
20
23
  InvalidDataException,
21
24
  NotFoundException,
22
25
  TimeoutException,
@@ -37,11 +40,40 @@ from clue.plugin.helpers.token import get_username
37
40
  from clue.plugin.models import BulkEntry
38
41
  from clue.plugin.utils import Params
39
42
 
43
+ # Load environment variables from .env file if present
44
+ load_dotenv()
45
+
46
+ # List of function names that can be overridden using the @plugin.use decorator
47
+ # These functions define the core plugin behavior and can be customized per plugin
48
+ OVERRIDABLE_FUNCTIONS = [
49
+ "enrich", # Main enrichment function for processing selectors
50
+ "alternate_bulk_lookup", # Alternative bulk enrichment implementation
51
+ "liveness", # Kubernetes liveness probe endpoint
52
+ "readyness", # Kubernetes readiness probe endpoint
53
+ "run_action", # Function to execute plugin actions
54
+ "run_fetcher", # Function to execute plugin fetchers
55
+ "setup_actions", # Runtime action definition generation
56
+ "validate_token", # Custom authentication token validation
57
+ ]
58
+
40
59
 
41
60
  def default_validate_token():
42
- """A default validation function that pulls from the authorization header. Not used by default."""
61
+ """A default validation function that extracts Bearer tokens from the Authorization header.
62
+
63
+ This function is provided as a reference implementation but is not used by default.
64
+ Plugin developers can use this as a starting point for their own token validation.
65
+
66
+ Returns:
67
+ tuple[str | None, str | None]: A tuple containing (token, error_message).
68
+ - If successful: (extracted_token, None)
69
+ - If failed: (None, error_description)
70
+
71
+ Note:
72
+ Expects Authorization header format: "Bearer <token>"
73
+ """
43
74
  token = request.headers.get("Authorization", None, type=str)
44
75
  if token and " " in token:
76
+ # Split "Bearer <token>" and extract the token part
45
77
  token = token.split()[1]
46
78
 
47
79
  if token:
@@ -51,21 +83,46 @@ def default_validate_token():
51
83
 
52
84
 
53
85
  def liveness(**_):
54
- "Default liveness probe"
86
+ """Default liveness probe for Kubernetes health checks.
87
+
88
+ This endpoint indicates whether the application is running and alive.
89
+ Returns a simple "OK" response with 200 status code.
90
+
91
+ Returns:
92
+ Response: Flask response with "OK" message
93
+ """
55
94
  return make_response("OK")
56
95
 
57
96
 
58
97
  def readyness(**_):
59
- "Default readyness probe"
98
+ """Default readiness probe for Kubernetes health checks.
99
+
100
+ This endpoint indicates whether the application is ready to serve traffic.
101
+ Returns a simple "OK" response with 200 status code.
102
+
103
+ Returns:
104
+ Response: Flask response with "OK" message
105
+ """
60
106
  return make_response("OK")
61
107
 
62
108
 
63
109
  def build_default_logger() -> logging.Logger:
64
- "Configure a default logger if one is not provided."
110
+ """Configure a default logger with standard Clue formatting when none is provided.
111
+
112
+ Creates a logger with INFO level that outputs to console using the standard
113
+ Clue log format and date format for consistency across all plugins.
114
+
115
+ Returns:
116
+ logging.Logger: Configured logger instance ready for use
117
+
118
+ Note:
119
+ Uses logger name "clue.plugin.default" to distinguish from user-provided loggers
120
+ """
65
121
  logger = logging.getLogger("clue.plugin.default")
66
122
  logger.setLevel(logging.INFO)
67
123
  console = logging.StreamHandler()
68
124
  console.setLevel(logging.INFO)
125
+ # Apply standard Clue log formatting for consistency
69
126
  console.setFormatter(logging.Formatter(CLUE_LOG_FORMAT, CLUE_DATE_FORMAT))
70
127
  logger.addHandler(console)
71
128
 
@@ -223,7 +280,7 @@ class CluePlugin:
223
280
  actions: list[Action] = [],
224
281
  alternate_bulk_lookup: Callable[[list[dict[str, str]], Params], dict[str, dict[str, BulkEntry]]] | None = None,
225
282
  cache_timeout: int = 5 * 60, # five minute timeout
226
- classification: str = os.environ.get("CLASSIFICATION", "TLP:CLEAR"),
283
+ classification: str | None = os.environ.get("CLASSIFICATION", None),
227
284
  enable_apm: bool = False,
228
285
  enable_cache: Union[bool, Literal["redis"], Literal["local"]] = True,
229
286
  enrich: Callable[[str, str, Params, str | None], Union[list[QueryEntry], QueryEntry]] | None = None,
@@ -235,7 +292,7 @@ class CluePlugin:
235
292
  run_action: Callable[[Action, ExecuteRequest, str | None], ActionResult] | None = None,
236
293
  run_fetcher: Callable[[FetcherDefinition, Selector, str | None], FetcherResult] | None = None,
237
294
  setup_actions: Callable[[list[Action], str | None], list[Action]] | None = None,
238
- supported_types: set[str] | None = None,
295
+ supported_types: set[str] | str | None = None,
239
296
  validate_token: Callable[[], tuple[str | None, str | None]] | None = None,
240
297
  ) -> None:
241
298
  """Helper class for creating clue plugins with proper server responses and behaviour.
@@ -300,16 +357,31 @@ class CluePlugin:
300
357
  A readyness probe for kubernetes implementations of Clue.
301
358
  """
302
359
  self.alternate_bulk_lookup = alternate_bulk_lookup
360
+ # Create Flask app using the module name (before first dot) as app name
303
361
  self.app = Flask(__name__.split(".")[0])
304
362
  self.app_name = app_name
363
+
364
+ # Classification is required for security - must be specified via env var or parameter
365
+ if classification is None:
366
+ raise ClueValueError(
367
+ "Classification must be specified, either via the CLASSIFICATION environment variable, or when "
368
+ "intializing the plugin."
369
+ )
370
+
305
371
  self.classification = classification
306
372
  self.liveness = liveness
307
373
  self.readyness = readyness
308
- self.supported_types = supported_types
374
+
375
+ # Convert comma-separated string to set for easier membership testing
376
+ if isinstance(supported_types, str):
377
+ self.supported_types = set(supported_types.split(","))
378
+ else:
379
+ self.supported_types = supported_types
309
380
 
310
381
  self.actions = actions
311
382
  self.setup_actions = setup_actions
312
383
 
384
+ # Allow URLs with or without trailing slashes to match the same route
313
385
  self.app.url_map.strict_slashes = False
314
386
 
315
387
  self.logger = logger if logger else build_default_logger()
@@ -323,20 +395,25 @@ class CluePlugin:
323
395
 
324
396
  self.__init_routes()
325
397
 
398
+ # Initialize Application Performance Monitoring if enabled
326
399
  if enable_apm:
327
400
  self.__init_apm()
328
401
 
402
+ # Set up caching based on configuration
329
403
  if enable_cache:
330
- # We support either using a boolean to use the redis default caching, or
404
+ # Support both boolean (use default cache type) and explicit cache type specification
331
405
  if isinstance(enable_cache, bool):
406
+ # Use environment variable or default to redis
407
+ cache_type = cast(Union[Literal["redis"], Literal["local"]], os.environ.get("CACHE_TYPE", "redis"))
332
408
  self.cache = Cache(
333
409
  self.app_name,
334
410
  self.app,
335
- cast(Union[Literal["redis"], Literal["local"]], os.environ.get("CACHE_TYPE", "redis")),
411
+ cache_type,
336
412
  timeout=cache_timeout,
337
413
  local_cache_options=local_cache_options,
338
414
  )
339
415
  else:
416
+ # Use explicitly specified cache type
340
417
  self.cache = Cache(
341
418
  self.app_name,
342
419
  self.app,
@@ -347,69 +424,128 @@ class CluePlugin:
347
424
  else:
348
425
  self.cache = None
349
426
 
427
+ # Configure werkzeug (Flask's WSGI server) logging to reduce noise
428
+ # Set to WARNING level to suppress INFO messages about HTTP requests
350
429
  wlog = logging.getLogger("werkzeug")
351
430
  wlog.setLevel(logging.WARNING)
431
+ # If our logger has a parent, inherit its handlers for consistency
352
432
  if self.logger.parent: # pragma: no cover
353
433
  for h in self.logger.parent.handlers:
354
434
  wlog.addHandler(h)
355
435
 
436
+ # Automatically inject the Flask "app" variable into the calling module's global namespace
437
+ # for compatibility with WSGI servers like gunicorn.
438
+ #
439
+ # This mechanism allows plugin developers to simply instantiate a CluePlugin without
440
+ # needing to explicitly expose the underlying Flask app. WSGI servers typically expect
441
+ # to find an 'app' variable in the module's global scope when using module:variable
442
+ # syntax (e.g., "mymodule:app").
443
+ #
444
+ # Example usage in a plugin module:
445
+ # plugin = CluePlugin("my-plugin", ...)
446
+ # # The 'app' variable is now automatically available for gunicorn
447
+ # # Command: gunicorn mymodule:app
448
+ current_frame = inspect.currentframe()
449
+ if current_frame:
450
+ caller_frame = current_frame.f_back
451
+ if caller_frame and "app" not in caller_frame.f_globals:
452
+ caller_frame.f_globals["app"] = self.app
453
+
356
454
  self.logger.debug("Initialization complete!")
357
455
 
358
456
  def __check_actions(self) -> list[Action] | None:
457
+ """Validate token and retrieve dynamic actions if setup_actions is configured.
458
+
459
+ This method handles token validation when required and calls the setup_actions
460
+ function to get a potentially user-specific or dynamically generated list of actions.
461
+
462
+ Returns:
463
+ list[Action] | None: List of actions if setup_actions is configured, None otherwise
464
+
465
+ Raises:
466
+ AuthenticationException: If token validation fails
467
+ """
359
468
  if self.setup_actions:
469
+ # Validate token if token validation is configured
360
470
  if self.validate_token:
361
471
  token, error = self.validate_token()
362
472
 
363
473
  if error:
364
474
  self.logger.error("Error on token validation: %s", error)
365
-
366
475
  raise AuthenticationException(error)
367
476
  else:
368
477
  token = None
369
478
 
479
+ # Call user-defined setup_actions with base actions and validated token
370
480
  return self.setup_actions(self.actions or [], token)
371
481
 
372
482
  return None
373
483
 
374
484
  def __init_apm(self):
375
- "Initializes the APM connection if enabled"
376
- # Setup APMs
485
+ """Initialize Application Performance Monitoring (APM) using Elastic APM.
486
+
487
+ Sets up ElasticAPM integration with Flask if APM_SERVER_URL environment
488
+ variable is configured. This enables automatic collection of performance
489
+ metrics, error tracking, and distributed tracing.
377
490
 
491
+ Environment Variables:
492
+ APM_SERVER_URL: URL of the Elastic APM server to send metrics to
493
+ """
494
+ # Check if APM server URL is configured via environment variable
378
495
  apm_server_url = os.environ.get("APM_SERVER_URL")
379
496
  if apm_server_url is None:
380
497
  return
381
498
 
382
- self.logger.debug("Initializing apm")
499
+ self.logger.debug("Initializing APM")
383
500
 
501
+ # Import ElasticAPM components (lazy import to avoid dependency issues)
384
502
  import elasticapm
385
503
  from elasticapm.contrib.flask import ElasticAPM
386
504
 
387
505
  self.logger.info(f"Exporting application metrics to: {apm_server_url}")
388
506
 
507
+ # Initialize ElasticAPM with Flask app and configure client
389
508
  ElasticAPM(self.app, client=elasticapm.Client(server_url=apm_server_url, service_name=self.app_name))
390
509
 
391
510
  def __build_ctx(self):
392
- "Returns a wrap_ctx function to push the flask context into the greenlets"
393
- # Make a copy of the current context to pass it in the greenlets
511
+ """Create a context wrapper function for preserving Flask request context in greenlets.
512
+
513
+ Flask request context is thread-local and doesn't automatically propagate to
514
+ greenlets. This function captures the current request context and returns a
515
+ wrapper that pushes it into each greenlet before execution.
516
+
517
+ Returns:
518
+ Callable: A wrapper function that preserves Flask context and handles exceptions
519
+ """
520
+ # Capture the current Flask request context to propagate to greenlets
394
521
  current_req_ctx = _cv_request.get(None)
395
522
  reqctx = current_req_ctx.copy() if current_req_ctx else None
396
523
 
397
- # Push the request context into the greenlet
398
524
  def wrap_ctx(func: Callable, *args: Any, **kwargs) -> tuple[Any, Exception | None]:
525
+ """Wrapper that pushes Flask context and handles enrichment function execution.
526
+
527
+ Args:
528
+ func: The enrichment function to execute
529
+ *args: Arguments to pass to the function
530
+ **kwargs: Keyword arguments to pass to the function
531
+
532
+ Returns:
533
+ tuple[Any, Exception | None]: (result, exception) tuple
534
+ """
535
+ # Push the request context into this greenlet's scope
399
536
  if reqctx:
400
537
  reqctx.push()
401
538
 
402
539
  try:
403
540
  self.logger.debug("Executing enrichment function")
404
-
405
541
  return func(*args, **kwargs), None
406
542
  except NotFoundException:
543
+ # NotFoundException means no results found - return empty list, not an error
407
544
  self.logger.warning("NotFoundException thrown in greenlet")
408
-
409
545
  return [], None
410
546
  except ClueException as e:
547
+ # Other Clue exceptions should be propagated as errors
411
548
  self.logger.exception("ClueException thrown in greenlet")
412
-
413
549
  return None, e
414
550
 
415
551
  return wrap_ctx
@@ -421,58 +557,80 @@ class CluePlugin:
421
557
  params: Params,
422
558
  token: str | None,
423
559
  ):
424
- "Default bulk lookup that harnesses greenlets to multithread the provided enrich function"
560
+ """Default bulk lookup implementation using greenlets for concurrent enrichment.
561
+
562
+ This method processes multiple enrichment requests concurrently by spawning
563
+ greenlets (lightweight threads) for each item. It uses the single-item enrich
564
+ function to process each request while maintaining Flask request context. Note
565
+ that this may lead to inefficient lookups (e.g. making ten requests to a database,
566
+ instead of a single bulk query)
567
+
568
+ Args:
569
+ bulk_result: Dictionary to populate with results, keyed by type then value
570
+ items: List of items to enrich, each containing 'type' and 'value' keys
571
+ params: Request parameters including timeouts and limits
572
+ token: Authentication token to pass to enrichment functions
573
+ """
425
574
  self.logger.debug("Using default bulk lookup")
426
575
 
427
- # Submit the different requested items to the threadpool executor
576
+ # Create context wrapper to preserve Flask request context in greenlets
428
577
  wrap_ctx = self.__build_ctx()
578
+ # Limit pool size to prevent resource exhaustion: min(items, cpu_count * 5 + 4)
429
579
  thread_pool = gevent.pool.Pool(min(len(items), (os.cpu_count() or 0) * 5 + 4))
430
580
  greenlets: list[tuple[str, str, Greenlet]] = []
431
581
 
582
+ # Spawn a greenlet for each enrichment request
432
583
  for entry in items:
433
- # Request results for the type/value tuple
584
+ # Store type, value, and greenlet for later result processing
434
585
  greenlets.append(
435
586
  (
436
587
  entry["type"],
437
588
  entry["value"],
438
589
  thread_pool.spawn(
439
- wrap_ctx,
440
- self.enrich,
441
- entry["type"],
442
- entry["value"],
443
- params,
444
- token,
590
+ wrap_ctx, # Context wrapper function
591
+ self.enrich, # User's enrichment function
592
+ entry["type"], # Selector type
593
+ entry["value"], # Selector value
594
+ params, # Request parameters
595
+ token, # Authentication token
445
596
  ),
446
597
  )
447
598
  )
448
599
 
600
+ # Calculate remaining time until deadline
449
601
  timeout = params.deadline + params.max_timeout - time.time()
450
602
  self.logger.debug("Joining threadpool (timeout=%s)", timeout)
451
603
 
604
+ # Wait for all greenlets to complete or timeout
452
605
  thread_pool.join(timeout=timeout)
453
606
 
607
+ # Process results from all completed greenlets
454
608
  for type_name, value, greenlet in greenlets:
455
609
  greenlet_result = greenlet.value
456
610
 
611
+ # Check if greenlet completed successfully with results
457
612
  if greenlet_result is not None and greenlet_result[0] is not None:
458
613
  results: Union[list[QueryEntry], QueryEntry] = greenlet_result[0]
614
+ # Ensure results is always a list for consistent handling
459
615
  if not isinstance(results, list):
460
616
  results = [results]
461
617
 
462
618
  bulk_result[type_name][value] = BulkEntry(items=results)
463
619
 
620
+ # Cache successful results if caching is enabled
464
621
  if self.cache:
465
622
  self.logger.info("Caching results for selector %s:%s", type_name, value)
466
-
467
623
  try:
468
624
  self.cache.set(type_name, value, params, results)
469
625
  except KeyError:
470
626
  self.logger.warning("Selector not present in bulk result, skipping cache step")
471
627
  else:
628
+ # Handle errors: timeout, exceptions, or other failures
472
629
  error = "Request Timed Out"
473
630
  if greenlet_result is not None and greenlet_result[1] is not None:
474
631
  error = str(greenlet_result[1])
475
632
 
633
+ # Use greenlet exception if available, otherwise use our error message
476
634
  bulk_result[type_name][value] = BulkEntry(
477
635
  error=(error if not greenlet.exception else str(greenlet.exception))
478
636
  )
@@ -483,7 +641,19 @@ class CluePlugin:
483
641
  )
484
642
 
485
643
  def __init_routes(self):
486
- "Set up the routes for the flask server."
644
+ """Set up all Flask routes for the plugin API endpoints.
645
+
646
+ Registers the following endpoints:
647
+ - GET /actions/: List available actions
648
+ - POST /actions/<action_id>/: Execute a specific action
649
+ - GET /fetchers/: List available fetchers
650
+ - POST /fetchers/<fetcher_id>: Execute a specific fetcher
651
+ - GET /types/: List supported types
652
+ - GET /lookup/<type_name>/<value>/: Single enrichment lookup
653
+ - POST /lookup/: Bulk enrichment lookup
654
+ - GET /healthz/live: Liveness probe
655
+ - GET /healthz/ready: Readiness probe
656
+ """
487
657
  self.logger.debug("Initializing routes")
488
658
 
489
659
  self.app.add_url_rule("/actions/", self.get_actions.__name__, self.get_actions, methods=["GET"])
@@ -501,16 +671,38 @@ class CluePlugin:
501
671
  self.app.add_url_rule("/healthz/ready", self.readyness.__name__, self.readyness)
502
672
 
503
673
  def make_api_response(self: Self, data: Any, err: str = "", status_code: int = 200) -> Response:
504
- "Create a standard response for this API."
674
+ """Create a standardized JSON response for all API endpoints.
675
+
676
+ This method ensures consistent response format across all plugin endpoints,
677
+ handles automatic error extraction from result objects, and logs all requests.
678
+
679
+ Args:
680
+ data: The response data (will be JSON serialized)
681
+ err: Error message (if any)
682
+ status_code: HTTP status code (default: 200)
683
+
684
+ Returns:
685
+ Response: Flask response with standardized JSON structure
686
+
687
+ Response Format:
688
+ {
689
+ "api_response": <data>,
690
+ "api_error_message": <error_string>,
691
+ "api_status_code": <status_code>
692
+ }
693
+ """
694
+ # Extract error messages from specialized result objects
505
695
  if isinstance(data, FetcherResult) and data.outcome == "failure" and not err:
506
696
  err = data.error or err
507
697
 
508
698
  if isinstance(data, ActionResult) and data.outcome == "failure" and not err:
509
699
  err = data.summary or err
510
700
 
701
+ # Convert Pydantic models to dict for JSON serialization
511
702
  if isinstance(data, BaseModel):
512
703
  data = data.model_dump(mode="json", exclude_none=True)
513
704
 
705
+ # Log all API requests with method, path, status, and error (if any)
514
706
  self.logger.info("%s %s - %s%s", request.method, request.path, status_code, f": {err}" if err else "")
515
707
 
516
708
  return make_response(
@@ -525,7 +717,18 @@ class CluePlugin:
525
717
  )
526
718
 
527
719
  def get_type_names(self: Self) -> Response:
528
- "Return supported type names."
720
+ """Return the list of supported selector types with their classifications.
721
+
722
+ Returns:
723
+ Response: JSON response mapping each supported type to its classification level
724
+
725
+ Response Format:
726
+ {
727
+ "type1": "classification_level",
728
+ "type2": "classification_level",
729
+ ...
730
+ }
731
+ """
529
732
  return self.make_api_response({tname: self.classification for tname in sorted(self.supported_types or [])})
530
733
 
531
734
  def lookup(self: Self, type_name: str, value: str) -> Response: # noqa: C901
@@ -558,6 +761,7 @@ class CluePlugin:
558
761
  if not self.enrich or not self.supported_types:
559
762
  return self.make_api_response({}, err="Enrichment is not supported by this plugin.", status_code=400)
560
763
 
764
+ # Normalize generic "ip" type to specific "ipv4" or "ipv6" based on address format
561
765
  if type_name == "ip":
562
766
  is_ipv4 = isinstance(ipaddress.ip_address(value), ipaddress.IPv4Address)
563
767
  type_name = "ipv4" if is_ipv4 else "ipv6"
@@ -569,8 +773,9 @@ class CluePlugin:
569
773
 
570
774
  return self.make_api_response(None, str(e), 504)
571
775
 
776
+ # Double URL decode the value (required by API specification)
572
777
  value = ul.unquote(ul.unquote(value))
573
- # Invalid types must either be ignored, or return a 422
778
+ # Validate that the requested type is supported by this plugin
574
779
  if type_name not in self.supported_types:
575
780
  return self.make_api_response(
576
781
  None,
@@ -766,6 +971,7 @@ class CluePlugin:
766
971
  else:
767
972
  self.__default_bulk_lookup(bulk_result, remaining_items, params, token)
768
973
 
974
+ # Calculate how close we came to the deadline (positive = time remaining, negative = overrun)
769
975
  variance = params.deadline - time.time()
770
976
 
771
977
  if self.logger:
@@ -809,11 +1015,14 @@ class CluePlugin:
809
1015
 
810
1016
  results: dict[str, dict[str, Any]] = {}
811
1017
  for action in actions:
1018
+ # Extract base action fields (id, name, description, etc.)
812
1019
  schema = action.model_dump(mode="json", include=set(ActionBase.model_fields.keys()), exclude_none=True)
1020
+ # Generate JSON schema for the action's parameter type
813
1021
  schema["params"] = cast(
814
1022
  BaseModel, cast(type[Any], action.model_fields["params"].annotation).__args__[0]
815
1023
  ).model_json_schema()
816
1024
 
1025
+ # Convert to ActionSpec format and add to results
817
1026
  results[action.id] = ActionSpec.model_validate(schema).model_dump(mode="json", exclude_none=True)
818
1027
 
819
1028
  return self.make_api_response(results)
@@ -857,6 +1066,7 @@ class CluePlugin:
857
1066
  else:
858
1067
  self.logger.warning("No token validation provided. The access token will not be provided to the action.")
859
1068
 
1069
+ # Extract the parameter type from the action definition for validation
860
1070
  param_type: Any = action_to_run.model_fields["params"].annotation or Any
861
1071
 
862
1072
  try:
@@ -866,6 +1076,7 @@ class CluePlugin:
866
1076
 
867
1077
  return self.make_api_response(ActionResult(outcome="failure", summary="No request body specified."))
868
1078
 
1079
+ # Validate request body against the action's parameter schema
869
1080
  action_request: ExecuteRequest = TypeAdapter(param_type.__args__[0]).validate_python(
870
1081
  raw_request, context={"action": action_to_run}
871
1082
  )
@@ -904,26 +1115,34 @@ class CluePlugin:
904
1115
  return self.make_api_response(result)
905
1116
 
906
1117
  def get_fetchers(self: Self) -> Response:
907
- """Gets all the fetchers for this plugin.
1118
+ """Get all available fetchers for this plugin.
908
1119
 
909
- Variables:
910
- None
1120
+ Returns a dictionary of fetcher definitions, each containing the fetcher's
1121
+ schema including supported types, output format, and other metadata.
911
1122
 
912
1123
  Returns:
913
- { # Dictionary of fetchers
914
- "fetcher1": {
915
- ... # schema of the fetcher
916
- },
917
- ...
918
- }
1124
+ Response: JSON response containing fetcher definitions
1125
+
1126
+ Response Format:
1127
+ {
1128
+ "fetcher1": {
1129
+ "id": "fetcher1",
1130
+ "name": "Fetcher Name",
1131
+ "description": "Description",
1132
+ "supported_types": ["type1", "type2"],
1133
+ "output_format": "format",
1134
+ ...
1135
+ },
1136
+ ...
1137
+ }
919
1138
  """
920
1139
  if not self.fetchers:
921
1140
  self.logger.debug("No fetchers to show")
922
-
923
1141
  return self.make_api_response({})
924
1142
 
925
1143
  results: dict[str, dict[str, Any]] = {}
926
1144
  for fetcher in self.fetchers:
1145
+ # Serialize fetcher definition to JSON-compatible dict
927
1146
  schema = fetcher.model_dump(mode="json", exclude_none=True)
928
1147
  results[fetcher.id] = schema
929
1148
 
@@ -941,6 +1160,7 @@ class CluePlugin:
941
1160
  if not self.run_fetcher or not self.fetchers:
942
1161
  return self.make_api_response({}, err=f"{self.app_name} does not support any fetchers.", status_code=400)
943
1162
 
1163
+ # Find the requested fetcher by ID
944
1164
  fetcher_to_run = next((fetcher for fetcher in self.fetchers if fetcher.id == fetcher_id), None)
945
1165
  if not fetcher_to_run:
946
1166
  return self.make_api_response({}, err=f"Fetcher {fetcher_id} does not exist", status_code=404)
@@ -964,6 +1184,7 @@ class CluePlugin:
964
1184
  status_code=400,
965
1185
  )
966
1186
 
1187
+ # Validate request body as a Selector object
967
1188
  raw_request = Selector.model_validate(request.json)
968
1189
 
969
1190
  self.logger.info("Running fetcher '%s'", fetcher_id)
@@ -1006,3 +1227,58 @@ class CluePlugin:
1006
1227
  self.logger.info("Error Message: %s", result.error)
1007
1228
 
1008
1229
  return self.make_api_response(result, status_code=status_code)
1230
+
1231
+ def use(self, func: Callable):
1232
+ """Register a function to be used by the CluePlugin for specific operations.
1233
+
1234
+ This decorator allows you to register functions that will be called during various
1235
+ plugin operations. The function name must match one of the supported overridable
1236
+ functions defined in OVERRIDABLE_FUNCTIONS.
1237
+
1238
+ Supported function names and their purposes:
1239
+ - enrich: Main enrichment function for processing selectors
1240
+ - alternate_bulk_lookup: Alternative bulk enrichment implementation
1241
+ - liveness: Kubernetes liveness probe endpoint
1242
+ - readyness: Kubernetes readiness probe endpoint
1243
+ - run_action: Function to execute plugin actions
1244
+ - run_fetcher: Function to execute plugin fetchers
1245
+ - setup_actions: Runtime action definition generation
1246
+ - validate_token: Custom authentication token validation
1247
+
1248
+ Args:
1249
+ func: The function to register. The function name determines which plugin
1250
+ operation it will be used for.
1251
+
1252
+ Returns:
1253
+ The original function (allows use as a decorator).
1254
+
1255
+ Example:
1256
+ ```python
1257
+ plugin = CluePlugin("my_plugin")
1258
+
1259
+ @plugin.use
1260
+ def enrich(type_name: str, value: str, params: Params, token: str | None):
1261
+ # Your enrichment logic here
1262
+ return QueryEntry(...)
1263
+ ```
1264
+
1265
+ Note:
1266
+ If a function with the same name is already registered, a warning will be logged
1267
+ and the new function will replace the existing one.
1268
+ """
1269
+ function_name = func.__name__
1270
+ if function_name not in OVERRIDABLE_FUNCTIONS:
1271
+ self.logger.error(
1272
+ "%s is not a valid function to use in a clue plugin. Supported list: %s",
1273
+ function_name,
1274
+ ", ".join(OVERRIDABLE_FUNCTIONS),
1275
+ )
1276
+
1277
+ # Warn if overwriting an existing function
1278
+ if getattr(self, function_name) is not None:
1279
+ self.logger.warning("plugin.uses decorator is overwriting existing function: %s", function_name)
1280
+
1281
+ # Dynamically set the function as an attribute of this plugin instance
1282
+ setattr(self, function_name, func)
1283
+
1284
+ return func
@@ -13,6 +13,9 @@ from termcolor import colored
13
13
 
14
14
  from clue.plugin import CluePlugin
15
15
 
16
+ PLUGINS_PATH = Path(__file__).parent.parent.parent.parent / "plugins"
17
+ sys.path.insert(0, str(PLUGINS_PATH))
18
+
16
19
  TESTABLE_FUNCTIONS = [
17
20
  ("get_actions", None),
18
21
  ("execute_action", "run_action"),
@@ -50,7 +53,7 @@ class CustomTestClient(FlaskClient):
50
53
  "Custom test client to inject authorization headers"
51
54
 
52
55
  def open(self, *args, buffered=False, follow_redirects=False, **kwargs):
53
- "Overriden open funciton to inject auth header"
56
+ "Overriden open function to inject auth header"
54
57
  headers = kwargs.setdefault("headers", {})
55
58
 
56
59
  if "CLUE_ACCESS_TOKEN" in os.environ:
@@ -164,7 +167,7 @@ def main(): # noqa: C901
164
167
  if len(sys.argv) > 1:
165
168
  plugin_name = sys.argv[1]
166
169
 
167
- if not (Path(__file__).parent.parent.parent / "plugins" / plugin_name).exists():
170
+ if not (PLUGINS_PATH / plugin_name).exists():
168
171
  error(f"Plugin {plugin_name} does not exist.")
169
172
  plugin_name = None
170
173
 
@@ -175,7 +178,7 @@ def main(): # noqa: C901
175
178
  plugin_name = None
176
179
 
177
180
  try:
178
- _module = importlib.import_module(f"plugins.{plugin_name}.app")
181
+ _module = importlib.import_module(f"{plugin_name}.app")
179
182
  success(f"Initializing plugin {plugin_name} for interactivity")
180
183
  except Exception:
181
184
  error(f"Initializing plugin {plugin_name} for interactivity")
@@ -132,25 +132,7 @@ suppress-none-returning = true
132
132
  "clue/plugin/interactive.py" = ["T201"]
133
133
  "clue/remote/datatypes/*" = ["D", "ANN", "C901"]
134
134
  "clue/security/__init__.py" = ["TRY301"]
135
- "plugins/nw_retention/app.py" = ["D200", "D205"]
136
- "plugins/oracle/*" = [
137
- "F403",
138
- "D103",
139
- "D205",
140
- "F841",
141
- "C901",
142
- "ANN001",
143
- "E501",
144
- "T201",
145
- "TRY301",
146
- "TRY002",
147
- "S113",
148
- "F811",
149
- "I001",
150
- "N806",
151
- "TRY004",
152
- ]
153
-
135
+ "test/conftest.py" = ["E402"]
154
136
 
155
137
  ###################
156
138
  # pytest settings #
@@ -165,7 +147,7 @@ log_cli_level = "WARN"
165
147
  [tool.poetry]
166
148
  package-mode = true
167
149
  name = "clue-api"
168
- version = "1.0.1.dev53"
150
+ version = "1.0.1.dev58"
169
151
  description = "Clue distributed enrichment service"
170
152
  authors = ["Canadian Centre for Cyber Security <contact@cyber.gc.ca>"]
171
153
  license = "MIT"
@@ -257,7 +239,7 @@ last_success = "build_scripts.last_success:main"
257
239
  check_changes = "build_scripts.check_changes:main"
258
240
  type_check = "build_scripts.type_check:main"
259
241
  coverage_report = "build_scripts.coverage_reports:main"
260
-
242
+ plugin = "clue.plugin.interactive:main"
261
243
 
262
244
  [tool.poetry.group.test.dependencies]
263
245
  pytest = "^8.1.1"
File without changes
File without changes