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
@@ -0,0 +1,109 @@
1
+ from flask import request
2
+ from pydantic import ValidationError
3
+
4
+ from clue.api import bad_request, make_subapi_blueprint, no_content, ok
5
+ from clue.common.logging import get_logger
6
+ from clue.common.swagger import generate_swagger_docs
7
+ from clue.config import config, get_redis
8
+ from clue.models.config import ExternalSource
9
+ from clue.remote.datatypes.set import Set
10
+ from clue.security import api_login
11
+
12
+ logger = get_logger(__file__)
13
+
14
+ EXTERNAL_PLUGIN_SET = Set("plugin_set", host=get_redis())
15
+
16
+ SUB_API = "registration"
17
+ registration_api = make_subapi_blueprint(SUB_API, api_version=1)
18
+ registration_api._doc = "Register external plugins"
19
+
20
+
21
+ @generate_swagger_docs()
22
+ @registration_api.route("/register/", methods=["POST"])
23
+ @api_login()
24
+ def register_application(**kwargs):
25
+ """Register the plugin given the provided data via REST API.
26
+
27
+ Variables:
28
+ None
29
+
30
+ Arguments:
31
+ None
32
+
33
+ API Call Examples:
34
+ /api/v1/registration/register/
35
+
36
+ Data Block:
37
+ [
38
+ {
39
+ "name": "test",
40
+ "classification": "TLP:CLEAR",
41
+ "max_classification": "TLP:CLEAR",
42
+ "url": "http://localhost:5008/",
43
+ "maintainer": "APA2B <apa2b-dl@cyber.gc.ca>",
44
+ "datahub_link": "http://example.com",
45
+ "documentation_link": "http://example.com"
46
+ },
47
+ ]
48
+
49
+ Result Example:
50
+ {
51
+ "api_response": "test", # The response from the API
52
+ "api_error_message": "", # Error message returned by the API
53
+ "api_warning": [], # List of warnings from the API
54
+ "api_server_version": "1.0.0.dev0", # Version of the API server
55
+ "api_status_code": 200 # Status code returned by the API
56
+ }
57
+
58
+ """
59
+ if not request.json:
60
+ return bad_request(err="No data provided")
61
+
62
+ try:
63
+ registration_request = ExternalSource(**request.json, built_in=False)
64
+ except ValidationError:
65
+ return bad_request(err="Request data could not be converted to an ExternalSource object")
66
+
67
+ config.api.external_sources.append(registration_request)
68
+ EXTERNAL_PLUGIN_SET.add(registration_request.model_dump(mode="json", exclude_none=True))
69
+
70
+ return ok(data=registration_request.name)
71
+
72
+
73
+ @generate_swagger_docs()
74
+ @registration_api.route("<plugin_id>", methods=["DELETE"])
75
+ @api_login()
76
+ def remove_application(plugin_id: str, **kwargs):
77
+ """Remove the given plugin from the external_sources list via REST API.
78
+
79
+ Variables:
80
+ name => "test"
81
+
82
+ Optional Arguments:
83
+ None
84
+
85
+ API Call Examples:
86
+ /api/v1/registration/test
87
+
88
+ Result Example:
89
+ {
90
+ "response_status": "204 NO CONTENT" # HTTP status code
91
+ }
92
+ """
93
+ source_to_remove = None
94
+
95
+ for source in config.api.external_sources:
96
+ if source.name == plugin_id and source.built_in is False:
97
+ source_to_remove = source
98
+ break
99
+
100
+ if (
101
+ source_to_remove is not None
102
+ and source_to_remove.model_dump(mode="json", exclude_none=True) in EXTERNAL_PLUGIN_SET.members()
103
+ ):
104
+ config.api.external_sources.remove(source_to_remove)
105
+ EXTERNAL_PLUGIN_SET.remove(source_to_remove.model_dump(mode="json", exclude_none=True))
106
+ logger.info(no_content(data=source_to_remove.name))
107
+ return no_content(data=source_to_remove.name)
108
+
109
+ return no_content(data=f"No plugin found with id: {plugin_id}")
clue/api/v1/static.py ADDED
@@ -0,0 +1,94 @@
1
+ from pathlib import Path
2
+
3
+ from flask import request
4
+ from flask_cors import CORS
5
+
6
+ from clue.api import make_subapi_blueprint, not_found, ok
7
+ from clue.common.swagger import generate_swagger_docs
8
+ from clue.config import config
9
+ from clue.security import api_login
10
+
11
+ SUB_API = "static"
12
+ static_api = make_subapi_blueprint(SUB_API, api_version=1)
13
+ static_api._doc = "Fetch static documentation"
14
+
15
+ CORS(static_api, origins=config.ui.cors_origins, supports_credentials=True)
16
+
17
+
18
+ @generate_swagger_docs(responses={200: "A markdown file containing documentation"})
19
+ @static_api.route("/docs", methods=["GET"])
20
+ @api_login()
21
+ def serve_documentation(**kwargs) -> dict[str, str]:
22
+ """Returns all documentation or filtered documentation if given a url param of a file name or a path
23
+
24
+ Variables:
25
+ None
26
+
27
+ Arguments:
28
+ None
29
+
30
+ Result Example:
31
+ URL Link: /api/v1/static/docs?filter="howler"
32
+
33
+ {"howler-docs.md": "Markdown documentation of howler-docs.md"}
34
+
35
+ """
36
+ docs_filter = request.args.get("filter")
37
+
38
+ documentation_folder = Path.cwd() / "docs"
39
+
40
+ returned_files = {}
41
+
42
+ if docs_filter is None:
43
+ for file in documentation_folder.rglob("*"):
44
+ if file.is_file():
45
+ content = file.read_text(encoding="utf-8")
46
+ returned_files[file.name] = content
47
+ else:
48
+ for file in documentation_folder.rglob("*"):
49
+ if file.is_file() and docs_filter in file.name:
50
+ try:
51
+ content = file.read_text(encoding="utf-8")
52
+ returned_files[file.name] = content
53
+ except FileNotFoundError:
54
+ return not_found(err="The file was not found")
55
+
56
+ return ok(returned_files)
57
+
58
+
59
+ @generate_swagger_docs(responses={200: "A markdown file containing documentation"})
60
+ @static_api.route("/docs/<filename>", methods=["GET"])
61
+ @api_login()
62
+ def serve_documentation_file(filename: str, **kwargs) -> dict[str, str]:
63
+ """Returns the specific file asked for within the route param
64
+
65
+ Variables:
66
+ filename (str): the specific file requested with an extension (i.e. *.md)
67
+
68
+ Arguments:
69
+ None
70
+
71
+ Result Example:
72
+ URL Link: /api/v1/static/docs/alfred-docs.md
73
+
74
+ {"markdown": "Markdown documentation of howler-docs.md"}
75
+
76
+ """
77
+ documentation_folder = Path.cwd() / "documentation"
78
+
79
+ if "." not in filename:
80
+ # Assume it's markdown
81
+ filename = filename + ".md"
82
+
83
+ returned_file = {}
84
+
85
+ for file in documentation_folder.rglob("*"):
86
+ if file.is_file() and file.name == filename:
87
+ content = file.read_text(encoding="utf-8")
88
+
89
+ returned_file["markdown"] = content
90
+
91
+ if "markdown" not in returned_file:
92
+ return not_found(err="The file does not exist or is typed incorrectly.")
93
+
94
+ return ok(returned_file)
clue/app.py ADDED
@@ -0,0 +1,166 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ from typing import Any
5
+
6
+ import elasticapm
7
+ from authlib.integrations.flask_client import OAuth
8
+ from elasticapm.contrib.flask import ElasticAPM
9
+ from flasgger import Swagger
10
+ from flask import Flask
11
+ from flask.logging import default_handler
12
+ from prometheus_client import make_wsgi_app
13
+ from werkzeug.middleware.dispatcher import DispatcherMiddleware
14
+
15
+ from clue.api.base import api
16
+ from clue.api.v1 import apiv1
17
+ from clue.api.v1.actions import actions_api
18
+ from clue.api.v1.auth import auth_api
19
+ from clue.api.v1.configs import configs_api
20
+ from clue.api.v1.fetchers import fetchers_api
21
+ from clue.api.v1.lookup import lookup_api
22
+ from clue.api.v1.registration import registration_api
23
+ from clue.api.v1.static import static_api
24
+ from clue.common.logging import get_logger
25
+ from clue.config import DEBUG, SECRET_KEY, cache, config
26
+ from clue.cronjobs import setup_jobs as setup_cron_jobs
27
+ from clue.error import errors
28
+ from clue.healthz import healthz
29
+
30
+ SESSION_COOKIE_SAMESITE = os.environ.get("BRL_SESSION_COOKIE_SAMESITE", None)
31
+ HSTS_MAX_AGE = os.environ.get("BRL_HSTS_MAX_AGE", None)
32
+
33
+ logger = get_logger(__file__)
34
+
35
+ ##########################
36
+ # App settings
37
+ current_directory = os.path.dirname(__file__)
38
+
39
+ app = Flask("clue_api")
40
+ # Disable strict check on trailing slashes for endpoints
41
+ app.url_map.strict_slashes = False
42
+ app.config["JSON_SORT_KEYS"] = False
43
+
44
+ app.wsgi_app = DispatcherMiddleware(app.wsgi_app, {"/metrics": make_wsgi_app()}) # type: ignore[method-assign]
45
+
46
+ swagger_template = {
47
+ "info": {
48
+ "title": "Clue API",
49
+ "description": "Clue is designed to provide analysts with pertinent insights wherever they engage with "
50
+ "data. It serves as a single portal for all enrichments, offering pro-active enrichment capabilities and "
51
+ "on-demand execution and scaling. Clue also features advanced insight visualizations and reusable UI "
52
+ "components to enhance the user experience and streamline analytic workflows.",
53
+ }
54
+ }
55
+ swagger = Swagger(
56
+ app,
57
+ template=swagger_template,
58
+ config={
59
+ "headers": [],
60
+ "static_url_path": "/api/swagger_static",
61
+ "specs": [
62
+ {
63
+ "endpoint": "apispec_v1",
64
+ "route": "/api/apispec_v1.json",
65
+ "rule_filter": lambda rule: True, # all in
66
+ "model_filter": lambda tag: True, # all in
67
+ }
68
+ ],
69
+ "specs_route": "/api/docs",
70
+ },
71
+ )
72
+
73
+ cache.init_app(app)
74
+
75
+ app.logger.setLevel(60) # This completely turns off the flask logger
76
+
77
+ ssl_context = None
78
+ logger.debug("Using flask secret key %s", re.sub(r"(.{6}).+(.{6})", r"\1...\2", SECRET_KEY))
79
+ app.config.update(SESSION_COOKIE_SECURE=True, SECRET_KEY=SECRET_KEY, PREFERRED_URL_SCHEME="https")
80
+ if SESSION_COOKIE_SAMESITE:
81
+ if SESSION_COOKIE_SAMESITE in ["Strict", "Lax"]:
82
+ app.config.update(SESSION_COOKIE_SAMESITE=SESSION_COOKIE_SAMESITE)
83
+ else:
84
+ raise ValueError("SESSION_COOKIE_SAMESITE must be set to 'Strict', 'Lax', or None")
85
+
86
+ app.register_blueprint(healthz)
87
+ app.register_blueprint(api)
88
+ app.register_blueprint(apiv1)
89
+ app.register_blueprint(errors)
90
+ app.register_blueprint(auth_api)
91
+ app.register_blueprint(actions_api)
92
+ app.register_blueprint(configs_api)
93
+ app.register_blueprint(fetchers_api)
94
+ app.register_blueprint(lookup_api)
95
+ app.register_blueprint(registration_api)
96
+ app.register_blueprint(static_api)
97
+ # Setup OAuth providers
98
+ if config.auth.oauth.enabled:
99
+ providers = []
100
+ for name, provider in config.auth.oauth.providers.items():
101
+ raw_provider: dict[str, Any] = provider.model_dump(mode="json", exclude_none=True)
102
+
103
+ # Set provider name
104
+ raw_provider["name"] = name
105
+
106
+ # Remove clue specific fields from oAuth config
107
+ raw_provider.pop("auto_create", None)
108
+ raw_provider.pop("auto_sync", None)
109
+ raw_provider.pop("user_get", None)
110
+ raw_provider.pop("auto_properties", None)
111
+ raw_provider.pop("uid_regex", None)
112
+ raw_provider.pop("uid_format", None)
113
+ raw_provider.pop("user_groups", None)
114
+ raw_provider.pop("user_groups_data_field", None)
115
+ raw_provider.pop("user_groups_name_field", None)
116
+ raw_provider.pop("app_provider", None)
117
+
118
+ # Add the provider to the list of providers
119
+ providers.append(raw_provider)
120
+
121
+ if providers:
122
+ oauth = OAuth()
123
+ for raw_provider in providers:
124
+ oauth.register(**raw_provider)
125
+ oauth.init_app(app)
126
+
127
+ if config.auth.allow_apikeys:
128
+ logger.debug(f"Allowing API Key use. Registered keys: {','.join(config.auth.apikeys.keys())}")
129
+
130
+ # Setup logging
131
+ app.logger.setLevel(logger.getEffectiveLevel())
132
+ app.logger.removeHandler(default_handler)
133
+ if logger.parent:
134
+ for ph in logger.parent.handlers:
135
+ app.logger.addHandler(ph)
136
+
137
+ # Setup APMs
138
+ if config.core.metrics.apm_server.server_url is not None:
139
+ app.logger.info(f"Exporting application metrics to: {config.core.metrics.apm_server.server_url}")
140
+ ElasticAPM(
141
+ app, client=elasticapm.Client(server_url=config.core.metrics.apm_server.server_url, service_name="enrichment")
142
+ )
143
+
144
+ wlog = logging.getLogger("werkzeug")
145
+ wlog.setLevel(logging.WARNING)
146
+ if logger.parent: # pragma: no cover
147
+ for h in logger.parent.handlers:
148
+ wlog.addHandler(h)
149
+
150
+ # setup Cronjob
151
+ setup_cron_jobs()
152
+
153
+
154
+ def main():
155
+ """Runs the flask server"""
156
+ app.jinja_env.cache = {}
157
+ app.run(
158
+ host="0.0.0.0", # noqa: S104
159
+ debug=DEBUG,
160
+ port=int(os.getenv("FLASK_RUN_PORT", os.getenv("PORT", 5000))),
161
+ ssl_context=ssl_context,
162
+ )
163
+
164
+
165
+ if __name__ == "__main__":
166
+ main()
clue/cache/__init__.py ADDED
@@ -0,0 +1,129 @@
1
+ from hashlib import sha256
2
+ from typing import TYPE_CHECKING, Any, Literal, Self
3
+
4
+ from flask import Flask
5
+ from flask_caching import Cache as FlaskCache
6
+ from pydantic import TypeAdapter
7
+
8
+ from clue.common.logging import get_logger
9
+ from clue.config import get_redis
10
+ from clue.models.network import QueryEntry
11
+ from clue.remote.datatypes.hash import ExpiringHash
12
+
13
+ if TYPE_CHECKING:
14
+ from clue.plugin.utils import Params
15
+
16
+ logger = get_logger(__file__)
17
+
18
+
19
+ class Cache:
20
+ "Caching wrapped for local/redis cache"
21
+
22
+ __type: Literal["redis"] | Literal["local"]
23
+ __local_cache: FlaskCache | None
24
+ __redis_cache: ExpiringHash | None
25
+ __app: Flask
26
+
27
+ def __init__(
28
+ self: Self,
29
+ cache_name: str,
30
+ app: Flask,
31
+ type: Literal["redis"] | Literal["local"],
32
+ timeout: int = 5 * 60, # five minute timeout
33
+ local_cache_options: dict[str, Any] | None = None,
34
+ ):
35
+ self.__app = app
36
+ self.__type = type
37
+
38
+ logger.debug("Enabling cache, type %s", self.__type)
39
+ if self.__type == "local":
40
+ self.__local_cache = FlaskCache(
41
+ self.__app,
42
+ config=(
43
+ local_cache_options
44
+ if local_cache_options is not None
45
+ else {"CACHE_TYPE": "SimpleCache", "CACHE_DEFAULT_TIMEOUT": timeout}
46
+ ),
47
+ )
48
+ else:
49
+ self.__redis_cache = ExpiringHash(cache_name, host=get_redis(), ttl=timeout)
50
+
51
+ def __generate_hash(self: Self, type_name: str, value: str, params: "Params") -> str:
52
+ "Generate a sha256 hash based on the selector"
53
+ hash_data = sha256(type_name.encode())
54
+ hash_data.update(value.encode())
55
+
56
+ hash_data.update(str(params.annotate).encode())
57
+ hash_data.update(str(params.raw).encode())
58
+ hash_data.update(str(params.limit).encode())
59
+
60
+ key = hash_data.hexdigest()
61
+
62
+ return key
63
+
64
+ def set(self: Self, type_name: str, value: str, params: "Params", data: list[QueryEntry]):
65
+ "Add the result of a given enrichment to the cache"
66
+ key = self.__generate_hash(type_name, value, params)
67
+
68
+ try:
69
+ serialized_data = TypeAdapter(list[QueryEntry]).dump_python(
70
+ data, mode="json", exclude_none=True, exclude_unset=True
71
+ )
72
+
73
+ if self.__type == "local":
74
+ if self.__local_cache is None:
75
+ logger.warning("Local cache is None despite type being local")
76
+ return
77
+
78
+ self.__local_cache.set(key, serialized_data)
79
+ else:
80
+ if self.__redis_cache is None:
81
+ logger.warning("Redis cache is None despite type being redis")
82
+ return
83
+
84
+ self.__redis_cache.set(key, serialized_data)
85
+ except Exception:
86
+ logger.exception("Error on retrieval")
87
+ return None
88
+
89
+ def get(self: Self, type_name: str, value: str, params: "Params") -> list[QueryEntry] | None:
90
+ "Add the result of a given enrichment to the cache"
91
+ key = self.__generate_hash(type_name, value, params)
92
+
93
+ try:
94
+ if self.__type == "local":
95
+ if self.__local_cache is None:
96
+ return None
97
+
98
+ cached_result = self.__local_cache.get(key)
99
+ else:
100
+ if self.__redis_cache is None:
101
+ return None
102
+
103
+ cached_result = self.__redis_cache.get(key)
104
+
105
+ if not cached_result:
106
+ return None
107
+
108
+ if not isinstance(cached_result, list):
109
+ cached_result = [cached_result]
110
+
111
+ return TypeAdapter(list[QueryEntry]).validate_python(cached_result)
112
+ except Exception:
113
+ logger.exception("Error on cache retrieval")
114
+ return None
115
+
116
+ def delete(self: Self, type_name: str, value: str, params: "Params"):
117
+ "Remove data associated with a key from the cache"
118
+ key = self.__generate_hash(type_name, value, params)
119
+
120
+ try:
121
+ if self.__type == "local":
122
+ if self.__local_cache:
123
+ self.__local_cache.delete(key)
124
+ else:
125
+ if self.__redis_cache:
126
+ self.__redis_cache.pop(key)
127
+ except Exception:
128
+ logger.exception("Error on cache deletion")
129
+ return None
File without changes