isl-tool 1.1.0__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.
- isl_tool/__init__.py +7 -0
- isl_tool/__main__.py +4 -0
- isl_tool/cli.py +355 -0
- isl_tool/colors.py +36 -0
- isl_tool/lib.py +166 -0
- isl_tool/oidc.py +198 -0
- isl_tool-1.1.0.dist-info/METADATA +50 -0
- isl_tool-1.1.0.dist-info/RECORD +10 -0
- isl_tool-1.1.0.dist-info/WHEEL +4 -0
- isl_tool-1.1.0.dist-info/entry_points.txt +2 -0
isl_tool/__init__.py
ADDED
isl_tool/__main__.py
ADDED
isl_tool/cli.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import errno
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import webbrowser
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urljoin, urlsplit
|
|
9
|
+
|
|
10
|
+
from . import __version__, colors, oidc
|
|
11
|
+
from .lib import API
|
|
12
|
+
|
|
13
|
+
PORTS = [53682, 53683, 53684, 53685]
|
|
14
|
+
|
|
15
|
+
GITLAB = "https://gitlab.ethz.ch"
|
|
16
|
+
CLIENT_ID = "d23d5238b28b43203b4430eaa42de7caea7ed6b8cdf9021787417ae6576cfdbe"
|
|
17
|
+
|
|
18
|
+
api = API("https://isl-2026-1.inf.ethz.ch")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
program = argparse.ArgumentParser(
|
|
23
|
+
prog="isl-tool", description="Information Security Lab Tool (ETH Zürich)"
|
|
24
|
+
)
|
|
25
|
+
program.add_argument(
|
|
26
|
+
"-v",
|
|
27
|
+
"--version",
|
|
28
|
+
action="version",
|
|
29
|
+
version=__version__,
|
|
30
|
+
help="output the version number",
|
|
31
|
+
)
|
|
32
|
+
commands = program.add_subparsers(title="commands", metavar="command")
|
|
33
|
+
|
|
34
|
+
def add_command(name, description, action):
|
|
35
|
+
command = commands.add_parser(name, help=description, description=description)
|
|
36
|
+
command.set_defaults(action=action)
|
|
37
|
+
return command
|
|
38
|
+
|
|
39
|
+
add_command(
|
|
40
|
+
"login", "authenticate for access to student environment", lambda args: login()
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
command = add_command(
|
|
44
|
+
"submit",
|
|
45
|
+
"submit solution for grading",
|
|
46
|
+
lambda args: submit(args.exercise, args.file),
|
|
47
|
+
)
|
|
48
|
+
command.add_argument("exercise", help="name of exercise to be submitted")
|
|
49
|
+
command.add_argument("file", help="file containing solution to exercise")
|
|
50
|
+
|
|
51
|
+
add_command(
|
|
52
|
+
"results",
|
|
53
|
+
"list the current grading results for your submissions",
|
|
54
|
+
lambda args: results(),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
command = add_command(
|
|
58
|
+
"submission",
|
|
59
|
+
"show the submission file considered for the grading result of an exercise",
|
|
60
|
+
lambda args: submission(args.exercise),
|
|
61
|
+
)
|
|
62
|
+
command.add_argument("exercise", help="name of exercise")
|
|
63
|
+
|
|
64
|
+
# Equivalent of the help command commander adds implicitly
|
|
65
|
+
command = add_command(
|
|
66
|
+
"help",
|
|
67
|
+
"display help for command",
|
|
68
|
+
lambda args: commands.choices.get(args.command, program).print_help(),
|
|
69
|
+
)
|
|
70
|
+
command.add_argument("command", nargs="?", help="command to display help for")
|
|
71
|
+
|
|
72
|
+
args = program.parse_args()
|
|
73
|
+
if not hasattr(args, "action"):
|
|
74
|
+
program.print_help(sys.stderr)
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
try:
|
|
77
|
+
args.action(args)
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
sys.exit(130)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def auth_token_path():
|
|
83
|
+
return str(Path.home() / ".isl-auth-token")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def auth_token():
|
|
87
|
+
try:
|
|
88
|
+
with open(auth_token_path(), encoding="utf-8") as f:
|
|
89
|
+
return f.read().strip()
|
|
90
|
+
except OSError as e:
|
|
91
|
+
print(
|
|
92
|
+
colors.red(f"Error: unable to retrieve authentication token: {e}"),
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
print(file=sys.stderr)
|
|
96
|
+
print("Have you logged in using `isl-tool login` yet?", file=sys.stderr)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def write_stdout(data):
|
|
101
|
+
sys.stdout.write(data)
|
|
102
|
+
sys.stdout.flush()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CallbackServer(ThreadingHTTPServer):
|
|
106
|
+
# On Windows, SO_REUSEADDR allows binding a port that is already in use, which would defeat the port fallback
|
|
107
|
+
allow_reuse_address = os.name != "nt"
|
|
108
|
+
# Let handle_request() return regularly so that the login loop can check whether the callback was handled
|
|
109
|
+
timeout = 0.5
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def listen_on_first_available_port(handler, ports):
|
|
113
|
+
for port in ports:
|
|
114
|
+
try:
|
|
115
|
+
return CallbackServer(("127.0.0.1", port), handler), port
|
|
116
|
+
except OSError as err:
|
|
117
|
+
if err.errno != errno.EADDRINUSE:
|
|
118
|
+
raise
|
|
119
|
+
raise RuntimeError("No available callback port")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def obtain_id_token():
|
|
123
|
+
config = oidc.discovery(GITLAB, CLIENT_ID)
|
|
124
|
+
|
|
125
|
+
code_verifier = oidc.random_pkce_code_verifier()
|
|
126
|
+
state = oidc.random_state()
|
|
127
|
+
nonce = oidc.random_nonce()
|
|
128
|
+
|
|
129
|
+
redirect_uri = None
|
|
130
|
+
# (id_token, error) of the first handled callback
|
|
131
|
+
outcome = []
|
|
132
|
+
|
|
133
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
134
|
+
def do_GET(self):
|
|
135
|
+
url = urljoin(redirect_uri, self.path)
|
|
136
|
+
|
|
137
|
+
if urlsplit(url).path != "/callback":
|
|
138
|
+
return self.respond(404)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
tokens = oidc.authorization_code_grant(
|
|
142
|
+
config,
|
|
143
|
+
url,
|
|
144
|
+
pkce_code_verifier=code_verifier,
|
|
145
|
+
expected_state=state,
|
|
146
|
+
expected_nonce=nonce,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
self.respond(200, "Login successful. You can close this tab.")
|
|
150
|
+
outcome.append((tokens["id_token"], None))
|
|
151
|
+
except Exception as err:
|
|
152
|
+
self.respond(400, "Login failed.")
|
|
153
|
+
outcome.append((None, err))
|
|
154
|
+
|
|
155
|
+
def respond(self, status, body=""):
|
|
156
|
+
data = body.encode("utf-8")
|
|
157
|
+
self.send_response(status)
|
|
158
|
+
self.send_header("Content-Length", str(len(data)))
|
|
159
|
+
self.end_headers()
|
|
160
|
+
self.wfile.write(data)
|
|
161
|
+
|
|
162
|
+
def log_message(self, format, *args):
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
server, port = listen_on_first_available_port(CallbackHandler, PORTS)
|
|
166
|
+
try:
|
|
167
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
168
|
+
auth_url = oidc.build_authorization_url(
|
|
169
|
+
config,
|
|
170
|
+
{
|
|
171
|
+
"redirect_uri": redirect_uri,
|
|
172
|
+
"scope": "openid profile email",
|
|
173
|
+
"code_challenge": oidc.calculate_pkce_code_challenge(code_verifier),
|
|
174
|
+
"code_challenge_method": "S256",
|
|
175
|
+
"state": state,
|
|
176
|
+
"nonce": nonce,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
print(f"Listening on {redirect_uri}")
|
|
181
|
+
print("Opening browser for login...")
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
opened = webbrowser.open(auth_url)
|
|
185
|
+
except webbrowser.Error:
|
|
186
|
+
opened = False
|
|
187
|
+
if not opened:
|
|
188
|
+
print(f"Open this URL:\n{auth_url}")
|
|
189
|
+
|
|
190
|
+
while not outcome:
|
|
191
|
+
server.handle_request()
|
|
192
|
+
finally:
|
|
193
|
+
server.server_close()
|
|
194
|
+
|
|
195
|
+
id_token, error = outcome[0]
|
|
196
|
+
if error is not None:
|
|
197
|
+
raise error
|
|
198
|
+
return id_token
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def login():
|
|
202
|
+
try:
|
|
203
|
+
id_token = obtain_id_token()
|
|
204
|
+
except Exception as err:
|
|
205
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
token = api.login(id_token)
|
|
210
|
+
except Exception as err:
|
|
211
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
path = auth_token_path()
|
|
216
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
217
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
218
|
+
f.write(token)
|
|
219
|
+
print(colors.green("Login successful!"))
|
|
220
|
+
print(colors.gray(f"Authentication token stored at {path}"))
|
|
221
|
+
except OSError as e:
|
|
222
|
+
print(
|
|
223
|
+
f"Error: failed to write authentication token to home directory: {e}",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def status():
|
|
229
|
+
# Request container status from the API
|
|
230
|
+
try:
|
|
231
|
+
status = api.status(auth_token())
|
|
232
|
+
|
|
233
|
+
# Print the status
|
|
234
|
+
text = status["status"]
|
|
235
|
+
if text == "running":
|
|
236
|
+
text = colors.green(text)
|
|
237
|
+
elif text == "stopped":
|
|
238
|
+
text = colors.yellow(text)
|
|
239
|
+
elif text == "uninitialised":
|
|
240
|
+
text = colors.gray(text)
|
|
241
|
+
else:
|
|
242
|
+
text = colors.red(text)
|
|
243
|
+
print(f"Status:\t{text}")
|
|
244
|
+
|
|
245
|
+
# Print connection information
|
|
246
|
+
if status["status"] == "running":
|
|
247
|
+
print()
|
|
248
|
+
print(
|
|
249
|
+
f"Your container is available at {status['host']} on port {status['port']}."
|
|
250
|
+
)
|
|
251
|
+
print(
|
|
252
|
+
"You can connect to it using: "
|
|
253
|
+
+ colors.yellow(f"ssh student@{status['host']} -p {status['port']}")
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
except Exception as err:
|
|
257
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def reset():
|
|
261
|
+
# Request container reset from the API
|
|
262
|
+
try:
|
|
263
|
+
status = api.reset(auth_token())
|
|
264
|
+
|
|
265
|
+
# Print login credentials
|
|
266
|
+
print(colors.green("Successfully reset container!"))
|
|
267
|
+
print()
|
|
268
|
+
print("Username:\t" + colors.yellow("student"))
|
|
269
|
+
print("Password:\t" + colors.yellow(f"{status['password']}"))
|
|
270
|
+
print(
|
|
271
|
+
colors.italic(
|
|
272
|
+
"Take note of this password! You will need to reset your container to reset it!"
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
print()
|
|
276
|
+
|
|
277
|
+
# Print connection information
|
|
278
|
+
print(
|
|
279
|
+
f"Your container is available at {status['host']} on port {status['port']}."
|
|
280
|
+
)
|
|
281
|
+
print(
|
|
282
|
+
"You can connect to it using: "
|
|
283
|
+
+ colors.yellow(f"ssh student@{status['host']} -p {status['port']}")
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
except Exception as err:
|
|
287
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def start():
|
|
291
|
+
# Request container start from the API
|
|
292
|
+
try:
|
|
293
|
+
api.start(auth_token())
|
|
294
|
+
except Exception as err:
|
|
295
|
+
if str(err) == "container does not exist":
|
|
296
|
+
print("Container does not exist yet, resetting...")
|
|
297
|
+
print()
|
|
298
|
+
reset()
|
|
299
|
+
else:
|
|
300
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def stop():
|
|
304
|
+
# Request container stop from the API
|
|
305
|
+
try:
|
|
306
|
+
api.stop(auth_token())
|
|
307
|
+
except Exception as err:
|
|
308
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def submit(exercise, solution):
|
|
312
|
+
# Read solution file
|
|
313
|
+
try:
|
|
314
|
+
with open(solution, "rb") as f:
|
|
315
|
+
data = f.read()
|
|
316
|
+
except OSError as err:
|
|
317
|
+
print(colors.red(f"Error reading solution file: {err}"), file=sys.stderr)
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
# Submit solution for grading
|
|
321
|
+
try:
|
|
322
|
+
api.grade_exercise(
|
|
323
|
+
auth_token(), exercise, data.decode("utf-8", errors="replace"), write_stdout
|
|
324
|
+
)
|
|
325
|
+
except Exception as err:
|
|
326
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def results():
|
|
330
|
+
# Request grader results from the API
|
|
331
|
+
try:
|
|
332
|
+
results = api.results(auth_token())
|
|
333
|
+
if not results:
|
|
334
|
+
print("No submissions yet.")
|
|
335
|
+
else:
|
|
336
|
+
for exercise, score in results.items():
|
|
337
|
+
text = colors.gray("no submission")
|
|
338
|
+
if score is not None:
|
|
339
|
+
text = f"{score['received_points']}/{score['max_points']} points"
|
|
340
|
+
text = (
|
|
341
|
+
colors.green(text)
|
|
342
|
+
if score["received_points"] == score["max_points"]
|
|
343
|
+
else colors.yellow(text)
|
|
344
|
+
)
|
|
345
|
+
print(f"{exercise}: {text}")
|
|
346
|
+
except Exception as err:
|
|
347
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def submission(exercise):
|
|
351
|
+
# Request grader submission from the API
|
|
352
|
+
try:
|
|
353
|
+
write_stdout(api.submission(auth_token(), exercise))
|
|
354
|
+
except Exception as err:
|
|
355
|
+
print(colors.red(f"Error: {err}"), file=sys.stderr)
|
isl_tool/colors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""ANSI text styles, covering the subset of the `colors` npm package used by the Node.js version of isl-tool."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
if sys.platform == "win32":
|
|
7
|
+
from colorama import just_fix_windows_console
|
|
8
|
+
|
|
9
|
+
just_fix_windows_console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def supports_color():
|
|
13
|
+
if os.environ.get("FORCE_COLOR"):
|
|
14
|
+
return os.environ["FORCE_COLOR"] != "0"
|
|
15
|
+
if "NO_COLOR" in os.environ or os.environ.get("TERM") == "dumb":
|
|
16
|
+
return False
|
|
17
|
+
return sys.stdout.isatty()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
enabled = supports_color()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def style(open_code, close_code):
|
|
24
|
+
def apply(text):
|
|
25
|
+
if not enabled or not text:
|
|
26
|
+
return text
|
|
27
|
+
return f"\x1b[{open_code}m{text}\x1b[{close_code}m"
|
|
28
|
+
|
|
29
|
+
return apply
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
red = style(31, 39)
|
|
33
|
+
green = style(32, 39)
|
|
34
|
+
yellow = style(33, 39)
|
|
35
|
+
gray = style(90, 39)
|
|
36
|
+
italic = style(3, 23)
|
isl_tool/lib.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from http import HTTPStatus
|
|
2
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
|
6
|
+
from websockets.sync.client import connect
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class APIError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def status_text(status):
|
|
14
|
+
try:
|
|
15
|
+
return HTTPStatus(status).phrase
|
|
16
|
+
except ValueError:
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode(data):
|
|
21
|
+
return data.decode("utf-8", errors="replace")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def api_error(err):
|
|
25
|
+
response = err.response
|
|
26
|
+
if response is not None and response.status_code == HTTPStatus.UNAUTHORIZED:
|
|
27
|
+
return APIError("unauthorized request")
|
|
28
|
+
elif response is not None and response.status_code == HTTPStatus.FORBIDDEN:
|
|
29
|
+
return APIError("unauthorized user")
|
|
30
|
+
elif response is not None and response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
|
31
|
+
return APIError("too many requests (you have exceeded your rate limit)")
|
|
32
|
+
elif response is not None:
|
|
33
|
+
msg = f"request failed with status: {response.status_code} {status_text(response.status_code)}"
|
|
34
|
+
if response.content:
|
|
35
|
+
msg += "\n" + decode(response.content)
|
|
36
|
+
return APIError(msg)
|
|
37
|
+
else:
|
|
38
|
+
return APIError(f"request failed: {type(err).__name__}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ws_response_error(status, body):
|
|
42
|
+
# Prefer the message sent by the server (e.g. submission limit reached)
|
|
43
|
+
if body.strip():
|
|
44
|
+
return APIError(body.strip())
|
|
45
|
+
elif status == HTTPStatus.UNAUTHORIZED:
|
|
46
|
+
return APIError("unauthorized request")
|
|
47
|
+
elif status == HTTPStatus.FORBIDDEN:
|
|
48
|
+
return APIError("unauthorized user")
|
|
49
|
+
elif status == HTTPStatus.TOO_MANY_REQUESTS:
|
|
50
|
+
return APIError("too many requests (you have exceeded your rate limit)")
|
|
51
|
+
elif status == HTTPStatus.NOT_FOUND:
|
|
52
|
+
return APIError("invalid exercise name")
|
|
53
|
+
return APIError(f"request failed with status: {status} {status_text(status)}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class API:
|
|
57
|
+
def __init__(self, base_url):
|
|
58
|
+
self.base_url = base_url
|
|
59
|
+
|
|
60
|
+
def _get(self, path, token):
|
|
61
|
+
response = requests.get(
|
|
62
|
+
urljoin(self.base_url, path), headers={"Authorization": f"Bearer {token}"}
|
|
63
|
+
)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
return response
|
|
66
|
+
|
|
67
|
+
def login(self, auth_token):
|
|
68
|
+
try:
|
|
69
|
+
return decode(self._get("auth", auth_token).content)
|
|
70
|
+
except requests.RequestException as err:
|
|
71
|
+
if (
|
|
72
|
+
err.response is not None
|
|
73
|
+
and err.response.status_code == HTTPStatus.UNAUTHORIZED
|
|
74
|
+
):
|
|
75
|
+
raise APIError("invalid credentials") from err
|
|
76
|
+
else:
|
|
77
|
+
raise api_error(err) from err
|
|
78
|
+
|
|
79
|
+
def status(self, token):
|
|
80
|
+
try:
|
|
81
|
+
return self._get("env/status", token).json()
|
|
82
|
+
except requests.RequestException as err:
|
|
83
|
+
raise api_error(err) from err
|
|
84
|
+
|
|
85
|
+
def reset(self, token):
|
|
86
|
+
try:
|
|
87
|
+
return self._get("env/reset", token).json()
|
|
88
|
+
except requests.RequestException as err:
|
|
89
|
+
raise api_error(err) from err
|
|
90
|
+
|
|
91
|
+
def start(self, token):
|
|
92
|
+
try:
|
|
93
|
+
self._get("env/start", token)
|
|
94
|
+
except requests.RequestException as err:
|
|
95
|
+
if (
|
|
96
|
+
err.response is not None
|
|
97
|
+
and err.response.status_code == HTTPStatus.NOT_FOUND
|
|
98
|
+
):
|
|
99
|
+
raise APIError("container does not exist") from err
|
|
100
|
+
else:
|
|
101
|
+
raise api_error(err) from err
|
|
102
|
+
|
|
103
|
+
def stop(self, token):
|
|
104
|
+
try:
|
|
105
|
+
self._get("env/stop", token)
|
|
106
|
+
except requests.RequestException as err:
|
|
107
|
+
if (
|
|
108
|
+
err.response is not None
|
|
109
|
+
and err.response.status_code == HTTPStatus.NOT_FOUND
|
|
110
|
+
):
|
|
111
|
+
raise APIError("container does not exist") from err
|
|
112
|
+
else:
|
|
113
|
+
raise api_error(err) from err
|
|
114
|
+
|
|
115
|
+
def grade_exercise(
|
|
116
|
+
self, token, exercise, solution, output_callback=lambda data: None
|
|
117
|
+
):
|
|
118
|
+
url = urlsplit(urljoin(self.base_url, f"grader/submit/{exercise}"))
|
|
119
|
+
if url.scheme == "https":
|
|
120
|
+
url = url._replace(scheme="wss")
|
|
121
|
+
elif url.scheme == "http":
|
|
122
|
+
url = url._replace(scheme="ws")
|
|
123
|
+
else:
|
|
124
|
+
raise APIError(f"unsupported protocol in base URL: {url.scheme}:")
|
|
125
|
+
try:
|
|
126
|
+
# No keepalive pings: the grader stops reading (and thus answering pings) while grading
|
|
127
|
+
with connect(
|
|
128
|
+
urlunsplit(url),
|
|
129
|
+
additional_headers={"Authorization": f"Bearer {token}"},
|
|
130
|
+
ping_interval=None,
|
|
131
|
+
max_size=None,
|
|
132
|
+
) as ws:
|
|
133
|
+
ws.send(solution)
|
|
134
|
+
while True:
|
|
135
|
+
try:
|
|
136
|
+
data = ws.recv()
|
|
137
|
+
except ConnectionClosed as err:
|
|
138
|
+
# The grader closes the TCP connection without a close frame once it is done,
|
|
139
|
+
# so only a closure caused by an actual error (e.g. a reset) is a failure
|
|
140
|
+
if err.__cause__ is not None:
|
|
141
|
+
raise err.__cause__
|
|
142
|
+
return
|
|
143
|
+
output_callback(data if isinstance(data, str) else decode(data))
|
|
144
|
+
except InvalidStatus as err:
|
|
145
|
+
# Server rejected the upgrade (e.g. 403/429): use the body so its message isn't lost
|
|
146
|
+
raise ws_response_error(
|
|
147
|
+
err.response.status_code, decode(err.response.body or b"")
|
|
148
|
+
) from err
|
|
149
|
+
|
|
150
|
+
def results(self, token):
|
|
151
|
+
try:
|
|
152
|
+
return self._get("grader/results", token).json()
|
|
153
|
+
except requests.RequestException as err:
|
|
154
|
+
raise api_error(err) from err
|
|
155
|
+
|
|
156
|
+
def submission(self, token, exercise):
|
|
157
|
+
try:
|
|
158
|
+
return decode(self._get(f"grader/submission/{exercise}", token).content)
|
|
159
|
+
except requests.RequestException as err:
|
|
160
|
+
if (
|
|
161
|
+
err.response is not None
|
|
162
|
+
and err.response.status_code == HTTPStatus.NOT_FOUND
|
|
163
|
+
):
|
|
164
|
+
raise APIError("no submission or invalid exercise name") from err
|
|
165
|
+
else:
|
|
166
|
+
raise api_error(err) from err
|
isl_tool/oidc.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Minimal OpenID Connect client for the authorization code flow with PKCE (public client).
|
|
2
|
+
|
|
3
|
+
Implements the subset of the `openid-client` npm package used by the Node.js version of isl-tool.
|
|
4
|
+
As with `openid-client` (unless non-repudiation checks are enabled), the ID Token's signature is
|
|
5
|
+
not verified because it is received directly from the token endpoint over TLS. Its claims are.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from urllib.parse import parse_qs, parse_qsl, urlencode, urlsplit, urlunsplit
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
TIMEOUT = 30
|
|
18
|
+
CLOCK_TOLERANCE = 30
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OIDCError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Configuration:
|
|
26
|
+
def __init__(self, server_metadata, client_id):
|
|
27
|
+
self.server_metadata = server_metadata
|
|
28
|
+
self.client_id = client_id
|
|
29
|
+
|
|
30
|
+
def endpoint(self, name):
|
|
31
|
+
url = self.server_metadata.get(name)
|
|
32
|
+
if not isinstance(url, str):
|
|
33
|
+
raise OIDCError(f"authorization server metadata does not contain {name}")
|
|
34
|
+
return url
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_issuer(issuer):
|
|
38
|
+
return issuer.rstrip("/")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def discovery(server, client_id):
|
|
42
|
+
response = requests.get(
|
|
43
|
+
f"{normalize_issuer(server)}/.well-known/openid-configuration",
|
|
44
|
+
headers={"Accept": "application/json"},
|
|
45
|
+
timeout=TIMEOUT,
|
|
46
|
+
)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
metadata = response.json()
|
|
49
|
+
issuer = metadata.get("issuer") if isinstance(metadata, dict) else None
|
|
50
|
+
if not isinstance(issuer, str) or normalize_issuer(issuer) != normalize_issuer(
|
|
51
|
+
server
|
|
52
|
+
):
|
|
53
|
+
raise OIDCError(
|
|
54
|
+
'"issuer" in authorization server metadata does not match the expected value'
|
|
55
|
+
)
|
|
56
|
+
return Configuration(metadata, client_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def random_pkce_code_verifier():
|
|
60
|
+
return secrets.token_urlsafe(32)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def random_state():
|
|
64
|
+
return secrets.token_urlsafe(32)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def random_nonce():
|
|
68
|
+
return secrets.token_urlsafe(32)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def calculate_pkce_code_challenge(code_verifier):
|
|
72
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
73
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_authorization_url(config, parameters):
|
|
77
|
+
url = urlsplit(config.endpoint("authorization_endpoint"))
|
|
78
|
+
query = parse_qsl(url.query)
|
|
79
|
+
query.append(("client_id", config.client_id))
|
|
80
|
+
query.append(("response_type", "code"))
|
|
81
|
+
query.extend(parameters.items())
|
|
82
|
+
return urlunsplit(url._replace(query=urlencode(query)))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def authorization_code_grant(
|
|
86
|
+
config, current_url, pkce_code_verifier, expected_state, expected_nonce
|
|
87
|
+
):
|
|
88
|
+
url = urlsplit(current_url)
|
|
89
|
+
params = parse_qs(url.query, keep_blank_values=True)
|
|
90
|
+
|
|
91
|
+
def param(name):
|
|
92
|
+
values = params.get(name, [])
|
|
93
|
+
if len(values) > 1:
|
|
94
|
+
raise OIDCError(f'"{name}" parameter must be provided only once')
|
|
95
|
+
return values[0] if values else None
|
|
96
|
+
|
|
97
|
+
# Validate the authorization response
|
|
98
|
+
iss = param("iss")
|
|
99
|
+
if iss is None and config.server_metadata.get(
|
|
100
|
+
"authorization_response_iss_parameter_supported"
|
|
101
|
+
):
|
|
102
|
+
raise OIDCError('response parameter "iss" (issuer) missing')
|
|
103
|
+
if iss is not None and iss != config.server_metadata["issuer"]:
|
|
104
|
+
raise OIDCError('unexpected "iss" (issuer) response parameter value')
|
|
105
|
+
state = param("state")
|
|
106
|
+
if state is None:
|
|
107
|
+
raise OIDCError('response parameter "state" missing')
|
|
108
|
+
if state != expected_state:
|
|
109
|
+
raise OIDCError('unexpected "state" response parameter value')
|
|
110
|
+
error = param("error")
|
|
111
|
+
if error is not None:
|
|
112
|
+
description = param("error_description")
|
|
113
|
+
raise OIDCError(
|
|
114
|
+
f"authorization response from the server is an error: {error}"
|
|
115
|
+
+ (f" ({description})" if description else "")
|
|
116
|
+
)
|
|
117
|
+
if param("id_token") is not None or param("token") is not None:
|
|
118
|
+
raise OIDCError("implicit and hybrid flows are not supported")
|
|
119
|
+
code = param("code")
|
|
120
|
+
if not code:
|
|
121
|
+
raise OIDCError('response parameter "code" missing')
|
|
122
|
+
|
|
123
|
+
# Exchange the authorization code at the token endpoint
|
|
124
|
+
response = requests.post(
|
|
125
|
+
config.endpoint("token_endpoint"),
|
|
126
|
+
data={
|
|
127
|
+
"grant_type": "authorization_code",
|
|
128
|
+
"code": code,
|
|
129
|
+
"redirect_uri": urlunsplit(url._replace(query="", fragment="")),
|
|
130
|
+
"code_verifier": pkce_code_verifier,
|
|
131
|
+
"client_id": config.client_id,
|
|
132
|
+
},
|
|
133
|
+
headers={"Accept": "application/json"},
|
|
134
|
+
timeout=TIMEOUT,
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
tokens = response.json()
|
|
138
|
+
except ValueError:
|
|
139
|
+
tokens = None
|
|
140
|
+
if response.status_code != 200:
|
|
141
|
+
if isinstance(tokens, dict) and isinstance(tokens.get("error"), str):
|
|
142
|
+
description = tokens.get("error_description")
|
|
143
|
+
raise OIDCError(
|
|
144
|
+
f"server responded with an error in the response body: {tokens['error']}"
|
|
145
|
+
+ (f" ({description})" if description else "")
|
|
146
|
+
)
|
|
147
|
+
raise OIDCError(
|
|
148
|
+
f"unexpected token endpoint response status code: {response.status_code}"
|
|
149
|
+
)
|
|
150
|
+
if not isinstance(tokens, dict):
|
|
151
|
+
raise OIDCError("token endpoint response body must be a JSON object")
|
|
152
|
+
for name in ("access_token", "token_type", "id_token"):
|
|
153
|
+
if not isinstance(tokens.get(name), str):
|
|
154
|
+
raise OIDCError(
|
|
155
|
+
f'token endpoint response body "{name}" property must be a string'
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
validate_id_token_claims(
|
|
159
|
+
config, decode_jwt_claims(tokens["id_token"]), expected_nonce
|
|
160
|
+
)
|
|
161
|
+
return tokens
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def decode_jwt_claims(jwt):
|
|
165
|
+
try:
|
|
166
|
+
_, payload, _ = jwt.split(".")
|
|
167
|
+
claims = json.loads(
|
|
168
|
+
base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
|
|
169
|
+
)
|
|
170
|
+
except ValueError as err:
|
|
171
|
+
raise OIDCError("failed to parse the ID Token") from err
|
|
172
|
+
if not isinstance(claims, dict):
|
|
173
|
+
raise OIDCError("ID Token claims set must be a JSON object")
|
|
174
|
+
return claims
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def validate_id_token_claims(config, claims, expected_nonce):
|
|
178
|
+
for claim in ("aud", "exp", "iat", "iss", "sub"):
|
|
179
|
+
if claim not in claims:
|
|
180
|
+
raise OIDCError(f'ID Token "{claim}" claim missing')
|
|
181
|
+
if claims["iss"] != config.server_metadata["issuer"]:
|
|
182
|
+
raise OIDCError('unexpected ID Token "iss" (issuer) claim value')
|
|
183
|
+
audience = claims["aud"] if isinstance(claims["aud"], list) else [claims["aud"]]
|
|
184
|
+
if config.client_id not in audience:
|
|
185
|
+
raise OIDCError('unexpected ID Token "aud" (audience) claim value')
|
|
186
|
+
if (len(audience) > 1 or "azp" in claims) and claims.get("azp") != config.client_id:
|
|
187
|
+
raise OIDCError('unexpected ID Token "azp" (authorized party) claim value')
|
|
188
|
+
if (
|
|
189
|
+
not isinstance(claims["exp"], (int, float))
|
|
190
|
+
or claims["exp"] <= time.time() - CLOCK_TOLERANCE
|
|
191
|
+
):
|
|
192
|
+
raise OIDCError(
|
|
193
|
+
'unexpected ID Token "exp" (expiration time) claim value, expiration is past current timestamp'
|
|
194
|
+
)
|
|
195
|
+
if not isinstance(claims["iat"], (int, float)):
|
|
196
|
+
raise OIDCError('ID Token "iat" (issued at) claim must be a number')
|
|
197
|
+
if claims.get("nonce") != expected_nonce:
|
|
198
|
+
raise OIDCError('unexpected ID Token "nonce" claim value')
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: isl-tool
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Information Security Lab Tool (ETH Zürich)
|
|
5
|
+
Author: csrrw
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: colorama>=0.4.6; sys_platform == 'win32'
|
|
8
|
+
Requires-Dist: requests>=2.27
|
|
9
|
+
Requires-Dist: websockets>=15.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# isl-tool
|
|
13
|
+
|
|
14
|
+
Information Security Lab Tool (ETH Zürich). Python port of the `isl-tool` npm
|
|
15
|
+
package.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Requires Python >= 3.9. Similar to `npx isl-tool@latest`, the tool can be run
|
|
20
|
+
without installing it, always using the most recent version, with
|
|
21
|
+
[uv](https://docs.astral.sh/uv/) or [pipx](https://pipx.pypa.io/):
|
|
22
|
+
|
|
23
|
+
```shell
|
|
24
|
+
uvx isl-tool@latest --help
|
|
25
|
+
pipx run --no-cache isl-tool --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Alternatively, install it with `pip install isl-tool` and invoke `isl-tool` (or
|
|
29
|
+
`python -m isl_tool`).
|
|
30
|
+
|
|
31
|
+
On first use, authenticate using the `login` command. This opens a browser for
|
|
32
|
+
logging in with ETH GitLab and stores the issued token in `~/.isl-auth-token`,
|
|
33
|
+
which subsequent commands use to authenticate.
|
|
34
|
+
|
|
35
|
+
```shell
|
|
36
|
+
isl-tool login
|
|
37
|
+
isl-tool submit <exercise> <file>
|
|
38
|
+
isl-tool results
|
|
39
|
+
isl-tool submission <exercise>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Publishing
|
|
43
|
+
|
|
44
|
+
Increment the version number in `src/isl_tool/__init__.py`, then build and upload the package:
|
|
45
|
+
|
|
46
|
+
```shell
|
|
47
|
+
python -m pip install --upgrade build twine
|
|
48
|
+
python -m build
|
|
49
|
+
python -m twine upload dist/*
|
|
50
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
isl_tool/__init__.py,sha256=7jxAOct8cgvgy9haLPqYpQ0hA5V3KW1LMlvy5gqD6cQ,151
|
|
2
|
+
isl_tool/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
|
|
3
|
+
isl_tool/cli.py,sha256=q0CotZFIXF38As9FCycNmb1FBMX9z0VWKyIUZQ0gJ5Q,10740
|
|
4
|
+
isl_tool/colors.py,sha256=jG0V4z9Ud7Kdqsfj3Oc5oXwL-C5fIHKTEPmdH0h0r7w,811
|
|
5
|
+
isl_tool/lib.py,sha256=4xNrxl_6NmKKuvcu4zsYwQ_AKJ68-vU3O10HgZ8lI-w,6120
|
|
6
|
+
isl_tool/oidc.py,sha256=eQ2yB3JIrf4VwLE5NL6PZLi-qccGd7C09N9j107zIc0,7125
|
|
7
|
+
isl_tool-1.1.0.dist-info/METADATA,sha256=IEMPqW5QszVijqenBF_LJrY0xmU0OQO6wUO-H9wTv5E,1336
|
|
8
|
+
isl_tool-1.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
isl_tool-1.1.0.dist-info/entry_points.txt,sha256=Oj28HN2vu5VR7SS1fO7Un2OAOZ1Cu1EIgJ3pfbxgv7c,47
|
|
10
|
+
isl_tool-1.1.0.dist-info/RECORD,,
|