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.
- clue/.gitignore +21 -0
- clue/__init__.py +0 -0
- clue/api/__init__.py +211 -0
- clue/api/base.py +99 -0
- clue/api/v1/__init__.py +82 -0
- clue/api/v1/actions.py +92 -0
- clue/api/v1/auth.py +243 -0
- clue/api/v1/configs.py +83 -0
- clue/api/v1/fetchers.py +94 -0
- clue/api/v1/lookup.py +221 -0
- clue/api/v1/registration.py +109 -0
- clue/api/v1/static.py +94 -0
- clue/app.py +166 -0
- clue/cache/__init__.py +129 -0
- clue/common/__init__.py +0 -0
- clue/common/classification.py +1006 -0
- clue/common/classification.yml +130 -0
- clue/common/dict_utils.py +130 -0
- clue/common/exceptions.py +199 -0
- clue/common/forge.py +152 -0
- clue/common/json_utils.py +10 -0
- clue/common/list_utils.py +11 -0
- clue/common/logging/__init__.py +291 -0
- clue/common/logging/audit.py +157 -0
- clue/common/logging/format.py +42 -0
- clue/common/regex.py +31 -0
- clue/common/str_utils.py +213 -0
- clue/common/swagger.py +139 -0
- clue/common/uid.py +47 -0
- clue/config.py +60 -0
- clue/constants/__init__.py +0 -0
- clue/constants/supported_types.py +38 -0
- clue/cronjobs/__init__.py +30 -0
- clue/cronjobs/plugins.py +32 -0
- clue/error.py +129 -0
- clue/gunicorn_config.py +29 -0
- clue/healthz.py +74 -0
- clue/helper/discover.py +53 -0
- clue/helper/headers.py +30 -0
- clue/helper/oauth.py +128 -0
- clue/models/__init__.py +0 -0
- clue/models/actions.py +243 -0
- clue/models/config.py +456 -0
- clue/models/fetchers.py +136 -0
- clue/models/graph.py +162 -0
- clue/models/model_list.py +52 -0
- clue/models/network.py +430 -0
- clue/models/results/__init__.py +34 -0
- clue/models/results/base.py +10 -0
- clue/models/results/graph.py +26 -0
- clue/models/results/image.py +22 -0
- clue/models/results/status.py +55 -0
- clue/models/results/validation.py +57 -0
- clue/models/selector.py +67 -0
- clue/models/utils.py +52 -0
- clue/models/validators.py +19 -0
- clue/patched.py +8 -0
- clue/plugin/__init__.py +1008 -0
- clue/plugin/helpers/__init__.py +0 -0
- clue/plugin/helpers/central_server.py +27 -0
- clue/plugin/helpers/email_render.py +228 -0
- clue/plugin/helpers/token.py +34 -0
- clue/plugin/helpers/trino.py +103 -0
- clue/plugin/interactive.py +270 -0
- clue/plugin/models.py +19 -0
- clue/plugin/utils.py +78 -0
- clue/remote/__init__.py +0 -0
- clue/remote/datatypes/__init__.py +130 -0
- clue/remote/datatypes/cache.py +62 -0
- clue/remote/datatypes/events.py +118 -0
- clue/remote/datatypes/hash.py +193 -0
- clue/remote/datatypes/queues/__init__.py +0 -0
- clue/remote/datatypes/queues/comms.py +62 -0
- clue/remote/datatypes/set.py +96 -0
- clue/remote/datatypes/user_quota_tracker.py +54 -0
- clue/security/__init__.py +211 -0
- clue/security/obo.py +95 -0
- clue/security/utils.py +34 -0
- clue/services/action_service.py +186 -0
- clue/services/auth_service.py +348 -0
- clue/services/config_service.py +38 -0
- clue/services/fetcher_service.py +203 -0
- clue/services/jwt_service.py +233 -0
- clue/services/lookup_service.py +786 -0
- clue/services/type_service.py +165 -0
- clue/services/user_service.py +152 -0
- clue_api-1.0.0.dev7.dist-info/METADATA +111 -0
- clue_api-1.0.0.dev7.dist-info/RECORD +91 -0
- clue_api-1.0.0.dev7.dist-info/WHEEL +4 -0
- clue_api-1.0.0.dev7.dist-info/entry_points.txt +8 -0
- 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("<", "<").replace(">", ">")
|
|
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)
|