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
File without changes
@@ -0,0 +1,27 @@
1
+ # import os
2
+
3
+ # from clue_client import get_client
4
+ # from clue_client.api.v1 import V1
5
+ # from flask import request
6
+
7
+ # from clue.common.logging import get_logger
8
+
9
+ # CENTRAL_SERVER_URL = os.getenv("CENTRAL_API_URL", "http://enrichment-rest.enrichment.svc.cluster.local:5000")
10
+
11
+ # logger = get_logger(__file__)
12
+
13
+
14
+ # def connect_to_central_server(timeout: int | None = 3, retries: int = 3) -> V1:
15
+ # "Connect to the central server using the clue client"
16
+ # access_token = request.headers.get("X-Clue-Authorization", None)
17
+
18
+ # if access_token:
19
+ # logger.info("X-Clue-Authorization header specified, using pre-OBO token")
20
+ # else:
21
+ # logger.warning("X-Clue-Authorization header not specified, falling back to core Authorization Header")
22
+ # access_token = request.headers.get("Authorization", " ", type=str).split(" ")[1]
23
+
24
+ # if not access_token:
25
+ # logger.warning("No token specified, continuing with no authentication")
26
+
27
+ # return get_client(CENTRAL_SERVER_URL, auth=access_token, version=1, timeout=timeout, retries=retries, logger=logger)
@@ -0,0 +1,228 @@
1
+ import base64
2
+ import email
3
+ import email.header
4
+ import io
5
+ import os
6
+ import quopri
7
+ import re
8
+ import tempfile
9
+ import textwrap
10
+ from email.errors import HeaderParseError
11
+ from email.message import Message
12
+ from tempfile import NamedTemporaryFile
13
+ from typing import cast
14
+
15
+ # TODO: Better handle these specific imports in dependency management
16
+ import imgkit
17
+ from bs4 import BeautifulSoup
18
+ from cart import unpack_stream
19
+ from PIL import Image
20
+
21
+ from clue.common.exceptions import ClueException, ClueRuntimeError
22
+ from clue.common.logging import get_logger
23
+ from clue.models.results.image import ImageResult
24
+
25
+ logger = get_logger(__file__)
26
+
27
+
28
+ TEXT_TYPES = ["text/plain", "text/html"]
29
+ IMAGE_TYPES = ["image/gif", "image/jpeg", "image/png"]
30
+
31
+
32
+ def append_images(images):
33
+ "Concatenate images together"
34
+ try:
35
+ bg_color = (255, 255, 255)
36
+ widths, heights = zip(*(i.size for i in images))
37
+
38
+ new_width = max(widths)
39
+ new_height = sum(heights)
40
+ new_im = Image.new("RGB", (new_width, new_height), color=bg_color)
41
+ offset = 0
42
+ for im in images:
43
+ x = 0
44
+ new_im.paste(im, (x, offset))
45
+ offset += im.size[1]
46
+ return new_im
47
+ except Exception as e:
48
+ raise ClueException("Error when appending images") from e
49
+
50
+
51
+ def get_header_data(msg: Message, header: str) -> str:
52
+ "Decode the value of a given header"
53
+ try:
54
+ decode = email.header.decode_header(msg[header])[0]
55
+
56
+ if isinstance(decode[0], bytes):
57
+ value = decode[0].decode()
58
+ else:
59
+ value = str(decode[0])
60
+ except HeaderParseError:
61
+ logger.warning("Could not parse header [%s], defaulting to Unknown", header)
62
+ value = "<Unknown>"
63
+ logger.info("%s: %s", header, value)
64
+ return value.replace("<", "&lt;").replace(">", "&gt;")
65
+
66
+
67
+ def filter_elements(payload: str) -> str:
68
+ "Filter external links from html"
69
+ logger.info("Performing trimming of external fonts/styles")
70
+ soup = BeautifulSoup(payload, "html.parser")
71
+
72
+ logger.debug("Checking for link elements")
73
+ for link in soup.select("link"):
74
+ logger.debug("Removing link tag: %s", link["href"])
75
+ link.decompose()
76
+
77
+ logger.debug("Checking for style elements with imports")
78
+ for style in soup.select("style"):
79
+ if style.string and "@import" in style.string:
80
+ _import_match = re.search(r"(@import.+;)", style.string)
81
+ if _import_match:
82
+ logger.debug("Removing import: %s", _import_match.group(1))
83
+ style.string = re.sub(r"@import.+;", "", style.string).strip()
84
+
85
+ if not style.string:
86
+ style.decompose()
87
+
88
+ logger.debug("Checking for script tags")
89
+ for script in soup.select("script"):
90
+ logger.debug("Removing script")
91
+ script.decompose()
92
+
93
+ return cast(str, soup.prettify())
94
+
95
+
96
+ def process_eml(data, output_dir, load_images=False): # noqa: C901
97
+ "Process the email (bytes), extract MIME parts and useful headers. Generate a PNG picture of the mail"
98
+ logger.debug("Beginning eml processing")
99
+
100
+ try:
101
+ msg = email.message_from_bytes(data)
102
+ date_field = get_header_data(msg, "Date")
103
+ from_field = get_header_data(msg, "From")
104
+ to_field = get_header_data(msg, "To")
105
+ subject_field = get_header_data(msg, "Subject")
106
+ id_field = get_header_data(msg, "Message-Id")
107
+
108
+ imgkit_options = {"load-error-handling": "skip", "no-images": None}
109
+
110
+ images_list = []
111
+
112
+ # Build a first image with basic mail details
113
+ headers = textwrap.dedent(f"""
114
+ <table width="100%%">
115
+ <tr><td align="right"><b>Date:</b></td><td>{date_field}</td></tr>
116
+ <tr><td align="right"><b>From:</b></td><td>{from_field}</td></tr>
117
+ <tr><td align="right"><b>To:</b></td><td>{to_field}</td></tr>
118
+ <tr><td align="right"><b>Subject:</b></td><td>{subject_field}</td></tr>
119
+ <tr><td align="right"><b>Message-Id:</b></td><td>{id_field}</td></tr>
120
+ </table>
121
+ <hr></p>
122
+ """)
123
+
124
+ try:
125
+ header_path = NamedTemporaryFile(suffix=".png").name
126
+ imgkit.from_string(headers, header_path, options=imgkit_options)
127
+ logger.info("Created headers: %s", header_path)
128
+ images_list.append(header_path)
129
+ except Exception:
130
+ logger.exception("Creation of headers failed.")
131
+
132
+ #
133
+ # Main loop - process the MIME parts
134
+ #
135
+ for part in msg.walk():
136
+ mime_type = part.get_content_type()
137
+ if part.is_multipart():
138
+ logger.info("Multipart found, continue")
139
+ continue
140
+
141
+ logger.info("Found MIME part: %s" % mime_type)
142
+ if mime_type in TEXT_TYPES:
143
+ try:
144
+ # Fix formatting
145
+ payload = part.get_payload(decode=True)
146
+ payload = re.sub(rb"(\r\n){1,}", b"\r\n", payload) # type: ignore[arg-type]
147
+ payload = payload.replace(b"\r\n", b"<br>")
148
+ payload = re.sub(rb"(<br> ){2,}", b"<br><br>", payload)
149
+
150
+ payload = quopri.decodestring(payload).decode("utf-8", errors="ignore")
151
+ except Exception:
152
+ payload = str(quopri.decodestring(part.get_payload(decode=True)))[2:-1] # type: ignore[arg-type]
153
+
154
+ payload = filter_elements(payload)
155
+
156
+ try:
157
+ payload_path = NamedTemporaryFile(suffix=".png").name
158
+ imgkit.from_string(payload, payload_path, options=imgkit_options)
159
+ logger.info("Decoded %s" % payload_path)
160
+ images_list.append(payload_path)
161
+ except Exception as e:
162
+ logger.warning(f"Decoding this MIME part returned error: {e}")
163
+
164
+ elif mime_type in IMAGE_TYPES and load_images:
165
+ payload = part.get_payload(decode=False)
166
+ payload_path = NamedTemporaryFile(suffix=".png").name
167
+ imgdata = base64.b64decode(payload) # type: ignore[arg-type]
168
+ try:
169
+ with open(payload_path, "wb") as f:
170
+ f.write(imgdata)
171
+ logger.info("Decoded %s" % payload_path)
172
+ images_list.append(payload_path)
173
+ except Exception as e:
174
+ logger.warning(f"Decoding this MIME part returned error: {e}")
175
+
176
+ result_image = os.path.join(output_dir, "output.png")
177
+ if len(images_list) > 0:
178
+ images = list(map(Image.open, images_list))
179
+ combo = append_images(images)
180
+ combo.save(result_image)
181
+ # Clean up temporary images
182
+ for i in images_list:
183
+ os.remove(i)
184
+ return result_image
185
+ else:
186
+ return False
187
+ except Exception as e:
188
+ logger.exception("Exception when processing eml")
189
+ raise ClueException("Error when processing email") from e
190
+
191
+
192
+ def render(email_path: str, cart_buffer: io.BytesIO) -> ImageResult | None:
193
+ "Helper function that, given a buffer containing a carted email, returns an image rendering of it."
194
+ cart_buffer.seek(0)
195
+ buf = io.BytesIO()
196
+ unpack_stream(cart_buffer, buf)
197
+ buf.seek(0)
198
+
199
+ logger.debug("Initializing temporary directory")
200
+ with tempfile.TemporaryDirectory() as tmp_dir:
201
+ logger.debug("Temporary directory initialized: %s", tmp_dir)
202
+
203
+ process_eml(
204
+ buf.read(),
205
+ tmp_dir,
206
+ )
207
+
208
+ error = None
209
+ if any("output" in s for s in os.listdir(tmp_dir)):
210
+ previews = [s for s in os.listdir(tmp_dir) if "output" in s]
211
+ if len(previews) == 0:
212
+ error = "Target file couldn't be converted to image."
213
+ elif len(previews) > 1:
214
+ error = "Target file is generating multiple images."
215
+ else:
216
+ error = "Output file does not exist."
217
+
218
+ if error:
219
+ raise ClueRuntimeError(error)
220
+
221
+ # There is only 1 rendered image
222
+ with open(f"{tmp_dir}/{previews[0]}", "rb") as f:
223
+ return ImageResult(
224
+ image=f"data:image/png;base64,{base64.b64encode(f.read()).decode("utf-8")}",
225
+ alt=f"Rendering of {email_path}",
226
+ )
227
+
228
+ logger.warning("No image result generated - returning None")
@@ -0,0 +1,34 @@
1
+ import base64
2
+ import json
3
+ import re
4
+ from typing import Optional, cast
5
+
6
+ from flask import request
7
+
8
+ from clue.common.exceptions import InvalidDataException
9
+
10
+
11
+ def get_username(token: Optional[str] = None, claims: list[str] | None = None):
12
+ "Get the username from a given token. Provide an optional list of claims to use"
13
+ if not token:
14
+ token = cast(str, request.headers.get("Authorization", None, type=str)).split()[1]
15
+
16
+ if not token or "." not in token:
17
+ raise InvalidDataException("Function requires a JWT to parse username")
18
+
19
+ jwt = json.loads(base64.b64decode(token.split(".")[1] + "==").decode())
20
+
21
+ if claims is None:
22
+ claims = ["email", "upn", "unique_name", "preferred_username"]
23
+
24
+ username = None
25
+ for claim in claims:
26
+ username = jwt.get(claim, None)
27
+
28
+ if username:
29
+ break
30
+
31
+ if not username:
32
+ username = re.sub(r"[^a-z]+", "_", jwt["name"].lower())
33
+
34
+ return username
@@ -0,0 +1,103 @@
1
+ import os
2
+ from typing import Any, Literal, cast
3
+
4
+ from flask import request
5
+ from trino.auth import JWTAuthentication
6
+ from trino.dbapi import Connection, Cursor, connect
7
+
8
+ from clue.common.logging import get_logger
9
+ from clue.plugin.helpers.token import get_username
10
+
11
+ logger = get_logger(__file__)
12
+
13
+
14
+ def get_trino_connection(
15
+ app_name: str,
16
+ connections: dict[str, Connection],
17
+ classification: Literal["u"] | Literal["pb"] = "pb",
18
+ request_timeout: int = 60,
19
+ max_attempts: int = 2,
20
+ username_claims: list[str] | None = None,
21
+ access_token: str | None = None,
22
+ ) -> Connection:
23
+ "Get a trino connection based on the provided JWT"
24
+ jwt_token: str = access_token or cast(str, request.headers.get("Authorization", None, type=str)).split(" ")[1]
25
+ if jwt_token not in connections:
26
+ connections[jwt_token] = connect(
27
+ http_scheme="https",
28
+ host=os.environ.get("TRINO_HOST", f"trino.hogwarts.{classification}.azure.chimera.cyber.gc.ca"),
29
+ port=int(os.environ.get("TRINO_PORT", "443")),
30
+ user=get_username(jwt_token, claims=username_claims),
31
+ auth=JWTAuthentication(jwt_token),
32
+ source=f"clue-{app_name}",
33
+ max_attempts=max_attempts,
34
+ request_timeout=request_timeout,
35
+ # This will stop trino from being bombarded with EXECUTE IMMEDIATE test queries
36
+ legacy_prepared_statements=False,
37
+ )
38
+
39
+ return connections[jwt_token]
40
+
41
+
42
+ def __prepare_query(
43
+ query: str, where_clause: str, limit: int | None, entries: list[list[str]] | list[str]
44
+ ) -> tuple[str, list[str]]:
45
+ num_where_args = len([character for character in list(where_clause) if character == "?"])
46
+ if num_where_args == 1 and any(isinstance(entry, list) for entry in entries):
47
+ logger.error(
48
+ "Invalid number of arguments provided for where clause. The where clause has one "
49
+ "?, but you provided a list of arguments."
50
+ )
51
+ return "invalid", []
52
+ elif num_where_args > 1 and not all(isinstance(entry, list) for entry in entries):
53
+ logger.error(
54
+ "Invalid number of arguments provided for where clause. The where clause has %s "
55
+ "?, but you did not provide a list of arguments.",
56
+ num_where_args,
57
+ )
58
+ return "invalid", []
59
+ elif num_where_args > 1 and not all(len(entry) == num_where_args for entry in entries):
60
+ logger.error(
61
+ "Invalid number of arguments provided for where clause. The where clause has %s "
62
+ "?, but you provided a list of arguments of length %s.",
63
+ num_where_args,
64
+ len(entries[0]),
65
+ )
66
+ return "invalid", []
67
+
68
+ final_query = query.strip()
69
+ if not final_query.strip().lower().endswith("where"):
70
+ final_query = final_query.strip() + " WHERE "
71
+ else:
72
+ final_query += " "
73
+
74
+ values = []
75
+ for entry in entries:
76
+ if not isinstance(entry, list):
77
+ values.append(entry)
78
+ else:
79
+ values += entry
80
+
81
+ final_query += f"({where_clause}) OR "
82
+
83
+ final_query = final_query[:-4]
84
+
85
+ if limit is not None:
86
+ final_query += f" LIMIT {limit}"
87
+
88
+ return final_query, values
89
+
90
+
91
+ def execute_bulk_query(
92
+ cur: Cursor, query: str, where_clause: str, limit: int | None, entries: list[list[str]] | list[str]
93
+ ) -> list[dict[str, Any]]:
94
+ "Build a bulk SQL query based on a main query, a template where clause, and a list of entries."
95
+ final_query, values = __prepare_query(query, where_clause, limit, entries)
96
+
97
+ cur.execute(final_query, values)
98
+
99
+ results: list[dict[str, Any]] = []
100
+ for row in cur.fetchall():
101
+ results.append(dict(zip([desc.name for desc in cur.description], row)))
102
+
103
+ return cur.fetchall()
@@ -0,0 +1,270 @@
1
+ import importlib
2
+ import inspect
3
+ import json
4
+ import os
5
+ import sys
6
+ import textwrap
7
+ from pathlib import Path
8
+ from typing import Callable
9
+ from urllib.parse import quote_plus
10
+
11
+ from flask.testing import FlaskClient
12
+ from termcolor import colored
13
+
14
+ from clue.plugin import CluePlugin
15
+
16
+ TESTABLE_FUNCTIONS = [
17
+ ("get_actions", None),
18
+ ("execute_action", "run_action"),
19
+ ("get_fetchers", None),
20
+ ("execute_fetcher", "run_fetcher"),
21
+ ("get_type_names", None),
22
+ ("lookup", "enrich"),
23
+ ("bulk_lookup", "enrich"),
24
+ ("liveness", None),
25
+ ("readyness", None),
26
+ ]
27
+
28
+
29
+ def success(*messages: str):
30
+ "Print success message"
31
+ print(f"[{colored("success", "green")}]", *messages)
32
+
33
+
34
+ def warn(*messages: str):
35
+ "Print error message"
36
+ print(f"[{colored("warn", "yellow")}]", *messages)
37
+
38
+
39
+ def error(*messages: str):
40
+ "Print error message"
41
+ print(f"[{colored("error", "red")}]", *messages)
42
+
43
+
44
+ def info(*messages: str):
45
+ "Print info message"
46
+ print(f"[{colored("info", "cyan")}]", *messages)
47
+
48
+
49
+ class CustomTestClient(FlaskClient):
50
+ "Custom test client to inject authorization headers"
51
+
52
+ def open(self, *args, buffered=False, follow_redirects=False, **kwargs):
53
+ "Overriden open funciton to inject auth header"
54
+ headers = kwargs.setdefault("headers", {})
55
+
56
+ if "CLUE_ACCESS_TOKEN" in os.environ:
57
+ info("Clue access token in env, setting Authorization header")
58
+ headers["Authorization"] = f"Bearer {os.environ["CLUE_ACCESS_TOKEN"]}"
59
+ else:
60
+ warn("Missing access token, skipping authorization header.")
61
+
62
+ return super().open(*args, buffered=buffered, follow_redirects=follow_redirects, **kwargs)
63
+
64
+
65
+ def filter_members(member, current_module):
66
+ "Get a filtered list of members exported by a given application"
67
+ member_module = inspect.getmodule(member)
68
+
69
+ if member_module is None:
70
+ return False
71
+
72
+ if member_module == current_module:
73
+ return True
74
+
75
+ if not member_module.__name__.startswith("clue"):
76
+ return False
77
+
78
+ return True
79
+
80
+
81
+ def test_function(plugin: CluePlugin, fn_id: str, fn: Callable): # noqa: C901
82
+ "test a function"
83
+ info(f"Executing test functionality for {fn_id}")
84
+
85
+ plugin.app.test_client_class = CustomTestClient
86
+
87
+ for rule in plugin.app.url_map.iter_rules():
88
+ if rule.endpoint != fn_id:
89
+ continue
90
+
91
+ if "GET" in (rule.methods or {}) and "<" not in rule.rule:
92
+ info("Simple endpoint detected. Running GET")
93
+ response = plugin.app.test_client().get(rule.rule)
94
+ info("Response:", json.dumps(response.json, indent=2) if response.json else response.data.decode())
95
+ elif "GET" in (rule.methods or {}):
96
+ kwargs: dict[str, str] = {}
97
+ info(f"{len(rule.arguments)} arguments are necessary. Supply them now:")
98
+ for argument in sorted(list(rule.arguments)):
99
+ kwargs[argument] = quote_plus(quote_plus(input(f"{argument}: ")))
100
+
101
+ with plugin.app.test_request_context():
102
+ path = plugin.app.url_for(fn_id, **kwargs) # type: ignore[arg-type]
103
+ info(f"Making request to path {path}")
104
+
105
+ response = plugin.app.test_client().get(path)
106
+
107
+ if response.status_code > 299:
108
+ error(
109
+ (response.json or {}).get(
110
+ "api_error_message", f"An unknown error occurred. Full response:\n{response.text}"
111
+ )
112
+ )
113
+ else:
114
+ info("Response:", json.dumps(response.json, indent=2) if response.json else response.data.decode())
115
+ elif "POST" in (rule.methods or {}):
116
+ kwargs: dict[str, str] = {}
117
+ if "<" in rule.rule:
118
+ info(f"{len(rule.arguments)} arguments are necessary. Supply them now:")
119
+ for argument in sorted(list(rule.arguments)):
120
+ kwargs[argument] = quote_plus(quote_plus(input(f"{argument}: ")))
121
+
122
+ info(
123
+ "Endpoint requires POST data. You can probide a JSON file for this data. "
124
+ f"Provide a path relative to {os.getcwd()} or an absolute path."
125
+ )
126
+ json_path = Path(os.getcwd()) / input("Path to JSON: ").strip()
127
+
128
+ if not json_path.exists() or json_path.is_dir():
129
+ error(f"Provided path {json_path} is invalid or is a directory.")
130
+ return
131
+
132
+ with json_path.open("r") as _data, plugin.app.test_request_context():
133
+ try:
134
+ post_data = json.load(_data)
135
+ except json.JSONDecodeError:
136
+ error(f"The file data in {json_path} is not valid JSON.")
137
+ return
138
+
139
+ api_path = rule.rule if "<" not in rule.rule else plugin.app.url_for(fn_id, **kwargs) # type: ignore[arg-type]
140
+
141
+ info(f"Submitting POST request to {api_path}:\n{post_data}")
142
+
143
+ response = plugin.app.test_client().post(
144
+ api_path,
145
+ data=json.dumps(post_data),
146
+ headers={"Content-Type": "application/json"},
147
+ )
148
+
149
+ if response.status_code > 299:
150
+ error(
151
+ (response.json or {}).get(
152
+ "api_error_message", f"An unknown error occurred. Full response:\n{response.text}"
153
+ )
154
+ )
155
+ else:
156
+ info("Response:", json.dumps(response.json, indent=2) if response.json else response.data.decode())
157
+
158
+
159
+ def main(): # noqa: C901
160
+ "main interactive loop"
161
+ os.environ["ENABLE_CACHE"] = "false"
162
+
163
+ plugin_name = None
164
+ if len(sys.argv) > 1:
165
+ plugin_name = sys.argv[1]
166
+
167
+ if not (Path(__file__).parent.parent.parent / "plugins" / plugin_name).exists():
168
+ error(f"Plugin {plugin_name} does not exist.")
169
+ plugin_name = None
170
+
171
+ while plugin_name is None:
172
+ plugin_name = input("What plugin do you want to interact with?\n> ")
173
+ if not (Path(__file__).parent.parent.parent / "plugins" / plugin_name).exists():
174
+ error(f"Plugin {plugin_name} does not exist.")
175
+ plugin_name = None
176
+
177
+ try:
178
+ _module = importlib.import_module(f"plugins.{plugin_name}.app")
179
+ success(f"Initializing plugin {plugin_name} for interactivity")
180
+ except Exception:
181
+ error(f"Initializing plugin {plugin_name} for interactivity")
182
+ raise
183
+
184
+ plugin: CluePlugin | None = None
185
+ for key, member in inspect.getmembers(_module, predicate=lambda _m: filter_members(_m, _module)):
186
+ if isinstance(member, CluePlugin):
187
+ success(f"Plugin found exported as member {key}")
188
+ plugin = member
189
+ break
190
+
191
+ if plugin is None:
192
+ error("CluePlugin object is not exported from this module!")
193
+ return
194
+
195
+ plugin.cache = None
196
+
197
+ functions: list[tuple[str, Callable]] = []
198
+
199
+ for attribute in dir(plugin):
200
+ test_entry = next((entry for entry in TESTABLE_FUNCTIONS if entry[0] == attribute), None)
201
+ if test_entry is None:
202
+ continue
203
+
204
+ fn = plugin.__getattribute__(attribute)
205
+ if fn is None:
206
+ continue
207
+
208
+ if test_entry[1] is not None:
209
+ helper_fn = plugin.__getattribute__(test_entry[1])
210
+
211
+ if helper_fn is None:
212
+ continue
213
+
214
+ functions.append((attribute, fn))
215
+
216
+ choice: int | None = None
217
+
218
+ print(
219
+ textwrap.dedent("""
220
+ Clue Plugin Development Script
221
+
222
+ This script will help you test various aspects of your plugin interactively.
223
+ """),
224
+ )
225
+
226
+ if "CLUE_ACCESS_TOKEN" not in os.environ:
227
+ warn(
228
+ textwrap.dedent("""
229
+ Environment variable CLUE_ACCESS_TOKEN not set!
230
+
231
+ It is highly likely your plugin will not work if it connects to an external service.
232
+
233
+ You'll need to generate a matching token. If on jupyhub, run:
234
+
235
+ export CLUE_ACCESS_TOKEN=$(python -c "from hogwarts.auth.vault.credentials import ClueTokenCredential; print(ClueTokenCredential().get_token().token)")
236
+
237
+ If you need a different token use the corresponding credential (i.e., trino -> TrinoTokenCredential, howler -> HowlerTokenCredential).
238
+
239
+ If you're developing on your local machine, talk to Matthew Rafuse <Matthew.Rafuse@cyber.gc.ca> on Teams.
240
+ """).strip() # noqa: E501
241
+ )
242
+
243
+ while choice is None:
244
+ print("\nAvailable functions:")
245
+
246
+ for i in range(len(functions)):
247
+ print(f"{i + 1}) {' '.join(word.capitalize() for word in functions[i][0].split("_"))}")
248
+ print(f"{len(functions) + 1}) Quit")
249
+
250
+ action = input("\nEnter a selection: ")
251
+
252
+ try:
253
+ choice = int(action)
254
+
255
+ if choice > len(functions) + 1:
256
+ error(f"Invalid choice, choose option between 1 - {len(functions)}.")
257
+ choice = None
258
+ except ValueError:
259
+ error(f"Invalid integer, choose option between 1 - {len(functions)}.")
260
+
261
+ if choice is not None and choice <= len(functions):
262
+ test_function(plugin, *functions[choice - 1])
263
+ choice = None
264
+
265
+
266
+ if __name__ == "__main__":
267
+ try:
268
+ main()
269
+ except KeyboardInterrupt:
270
+ print("\rExiting!" + " " * 80)
clue/plugin/models.py ADDED
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from clue.models.network import QueryEntry
6
+
7
+
8
+ class BulkEntry(BaseModel):
9
+ "Bulk plugin response for selectors"
10
+
11
+ error: str | None = Field(
12
+ description="Error message returned by plugin",
13
+ default=None,
14
+ examples=["An error occurred when enriching the data.", None],
15
+ )
16
+ items: list[QueryEntry] = Field(description="List of results from the plugin", default=[])
17
+ raw_data: Any | None = Field(default=None, description="The raw data for the given results")
18
+
19
+ model_config = ConfigDict(validate_assignment=True)