lib.grade_helpers
1import base64 2import posixpath 3import re 4import shlex 5from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface 6from typing import Union 7 8from SRE.lib_sre import Grade0 9 10 11def test_dig(grade: Grade0, machine_name: str, 12 server_ip: Union[str, IPv4Address, IPv4Interface, IPv6Address, IPv6Interface], *, 13 proto: str = "udp", port: int = 53, request: str, 14 timeout: int = 2, step: int = 1) -> tuple[str, int]: 15 """Run a ``dig +short`` query from a container and return ``(stdout, exit_code)``. 16 17 The command issued is:: 18 19 dig +time={timeout-1} +tries=1 +short [+tcp] -p {port} @{server_ip} {request} 20 21 Args: 22 grade: the Grade0 instance — required so the command is 23 registered in the first pass and re-fetched in the second. 24 machine_name: container the dig command is executed from. 25 server_ip: target DNS server. Accepts an IPv4/IPv6 literal string, an 26 ``IPv4Address`` / ``IPv6Address`` or an ``IPv4Interface`` / 27 ``IPv6Interface`` (the prefix is stripped). Substituted as 28 ``@server_ip`` (dig takes a bare IPv6 address, no brackets). 29 proto: ``"udp"`` (default) or ``"tcp"``; TCP adds ``+tcp``. 30 port: server port (default 53). 31 request: dig query body — everything that follows ``@server``, 32 e.g. ``"www.example.com A"`` or ``"example.com SOA"``. 33 timeout: total timeout in seconds passed to ``grade.test()`` (default 2). 34 ``dig`` itself is given ``+time={timeout-1}`` so it gives up 35 one second before the outer test, leaving room for clean exit. 36 step: step number passed to ``grade.test()`` (default 1). 37 38 Returns: 39 ``(stdout, exit_code)`` — stdout is stripped. On time-outs or unreachable 40 servers the output may be empty; the exit code from ``grade.test()`` is 41 passed through unchanged. Errors are not recorded (``allow_error=True``). 42 """ 43 if isinstance(server_ip, (IPv4Interface, IPv6Interface)): 44 server_ip_str = str(server_ip.ip) 45 elif isinstance(server_ip, (IPv4Address, IPv6Address)): 46 server_ip_str = str(server_ip) 47 else: 48 server_ip_str = server_ip 49 proto_flag = " +tcp" if proto.lower() == "tcp" else "" 50 cmd = (f"dig +time={timeout - 1} +tries=1 +short{proto_flag} " 51 f"-p {port} @{server_ip_str} {request}") 52 out, code = grade.test(machine_name, cmd, 53 step=step, timeout=timeout, allow_error=True) 54 return (out or "").strip(), code 55 56 57def eval_tcp_server(grade: Grade0, machine_name: str, server_name: str, 58 step: int = 1) -> list[int] | None: 59 """Check that a process matching server_name is running on machine_name and 60 return the TCP ports it listens on. 61 62 Args: 63 grade: the Grade0 instance. 64 machine_name: name of the virtual machine to inspect. 65 server_name: substring to match against running process names (ps output). 66 step: step number passed to grade.test() (default: 1). 67 68 Returns: 69 A list of TCP port numbers in LISTEN state used by the process, 70 or None if no matching process is found. 71 """ 72 # All grade.test() calls must be made unconditionally so they are registered 73 # in the first (registration) pass and carry real results in the second pass. 74 pids_out, pids_code = grade.test( 75 machine_name=machine_name, 76 command=f"pgrep -f {server_name}", 77 step=step, 78 ) 79 ss_out, _ = grade.test( 80 machine_name=machine_name, 81 command=f"ss -tlnp", 82 step=step, 83 ) 84 85 if pids_code != 0: 86 return None 87 88 pids = set(pids_out.split()) 89 if not pids: 90 return None 91 92 ports = [] 93 for line in ss_out.splitlines(): 94 # ss -tlnp output has a "users:(("name",pid=NNN,...))" field 95 if "LISTEN" not in line: 96 continue 97 if not any(f"pid={pid}" in line for pid in pids): 98 continue 99 # Extract port from the local address column: 0.0.0.0:443, 127.0.0.1:53, 100 # 127.0.0.53%lo:53, *:443 (dual-stack socket), [::]:443, [::1]:9000, [fd00::1%eth0]:80 101 addr_m = re.search(r'\s+(?:\*|\[[^\]]*\](?:%\w+)?|[0-9a-fA-F.:]+(?:%\w+)?):(\d+)\s+', line) 102 if addr_m: 103 ports.append(int(addr_m.group(1))) 104 105 return sorted(set(ports)) 106 107 108def transplant_files(grade: Grade0, src: str, dst: str, files: dict[str, str], 109 download_step: int = 1, apply_step: int = 2, 110 workdir: str = "/tmp/sre_transplant") -> dict[str, str]: 111 """Copy text files from the container *src* to the container *dst* during an evaluation. 112 113 *files* maps a path on *src* to a path on *dst* (normally under *workdir*). Each file is 114 read on *src* at *download_step* (``base64 -w0``) and, from the grade pass that follows 115 that step, re-created on *dst* at *apply_step* (``base64 -d``, mode 0600) — the two-step 116 contract of ``firewall.transplant_ruleset``: nothing is registered on *dst* for an empty 117 or missing file, so the command keys stay stable and no phantom "write of the empty 118 string" exists. *workdir* is wiped and re-created on *dst* at *download_step*, so a file 119 of a previous evaluation never survives. 120 121 Returns ``{src_path: content}`` with the decoded text of each file (``''`` until the 122 download step has run, or when the file is missing on *src*). 123 """ 124 grade.test(dst, f"rm -rf {shlex.quote(workdir)}; mkdir -p {shlex.quote(workdir)}", 125 step=download_step, allow_error=True) 126 contents: dict[str, str] = {} 127 for src_path, dst_path in files.items(): 128 b64, code = grade.test(src, f"base64 -w0 {shlex.quote(src_path)} 2>/dev/null", 129 step=download_step, allow_error=True) 130 b64 = (b64 or "").strip() 131 if code != 0 or not b64: 132 contents[src_path] = "" 133 continue 134 try: 135 contents[src_path] = base64.b64decode(b64, validate=True).decode("utf-8", errors="replace") 136 except (ValueError, UnicodeDecodeError): 137 contents[src_path] = "" 138 continue 139 parent = posixpath.dirname(dst_path) or "." 140 grade.test(dst, f"mkdir -p {shlex.quote(parent)} && echo {b64} | base64 -d > {shlex.quote(dst_path)}" 141 f" && chmod 600 {shlex.quote(dst_path)}", 142 step=apply_step, allow_error=True) 143 return contents
12def test_dig(grade: Grade0, machine_name: str, 13 server_ip: Union[str, IPv4Address, IPv4Interface, IPv6Address, IPv6Interface], *, 14 proto: str = "udp", port: int = 53, request: str, 15 timeout: int = 2, step: int = 1) -> tuple[str, int]: 16 """Run a ``dig +short`` query from a container and return ``(stdout, exit_code)``. 17 18 The command issued is:: 19 20 dig +time={timeout-1} +tries=1 +short [+tcp] -p {port} @{server_ip} {request} 21 22 Args: 23 grade: the Grade0 instance — required so the command is 24 registered in the first pass and re-fetched in the second. 25 machine_name: container the dig command is executed from. 26 server_ip: target DNS server. Accepts an IPv4/IPv6 literal string, an 27 ``IPv4Address`` / ``IPv6Address`` or an ``IPv4Interface`` / 28 ``IPv6Interface`` (the prefix is stripped). Substituted as 29 ``@server_ip`` (dig takes a bare IPv6 address, no brackets). 30 proto: ``"udp"`` (default) or ``"tcp"``; TCP adds ``+tcp``. 31 port: server port (default 53). 32 request: dig query body — everything that follows ``@server``, 33 e.g. ``"www.example.com A"`` or ``"example.com SOA"``. 34 timeout: total timeout in seconds passed to ``grade.test()`` (default 2). 35 ``dig`` itself is given ``+time={timeout-1}`` so it gives up 36 one second before the outer test, leaving room for clean exit. 37 step: step number passed to ``grade.test()`` (default 1). 38 39 Returns: 40 ``(stdout, exit_code)`` — stdout is stripped. On time-outs or unreachable 41 servers the output may be empty; the exit code from ``grade.test()`` is 42 passed through unchanged. Errors are not recorded (``allow_error=True``). 43 """ 44 if isinstance(server_ip, (IPv4Interface, IPv6Interface)): 45 server_ip_str = str(server_ip.ip) 46 elif isinstance(server_ip, (IPv4Address, IPv6Address)): 47 server_ip_str = str(server_ip) 48 else: 49 server_ip_str = server_ip 50 proto_flag = " +tcp" if proto.lower() == "tcp" else "" 51 cmd = (f"dig +time={timeout - 1} +tries=1 +short{proto_flag} " 52 f"-p {port} @{server_ip_str} {request}") 53 out, code = grade.test(machine_name, cmd, 54 step=step, timeout=timeout, allow_error=True) 55 return (out or "").strip(), code
Run a dig +short query from a container and return (stdout, exit_code).
The command issued is::
dig +time={timeout-1} +tries=1 +short [+tcp] -p {port} @{server_ip} {request}
Arguments:
- grade: the Grade0 instance — required so the command is registered in the first pass and re-fetched in the second.
- machine_name: container the dig command is executed from.
- server_ip: target DNS server. Accepts an IPv4/IPv6 literal string, an
IPv4Address/IPv6Addressor anIPv4Interface/IPv6Interface(the prefix is stripped). Substituted as@server_ip(dig takes a bare IPv6 address, no brackets). - proto:
"udp"(default) or"tcp"; TCP adds+tcp. - port: server port (default 53).
- request: dig query body — everything that follows
@server, e.g."www.example.com A"or"example.com SOA". - timeout: total timeout in seconds passed to
grade.test()(default 2).digitself is given+time={timeout-1}so it gives up one second before the outer test, leaving room for clean exit. - step: step number passed to
grade.test()(default 1).
Returns:
(stdout, exit_code)— stdout is stripped. On time-outs or unreachable servers the output may be empty; the exit code fromgrade.test()is passed through unchanged. Errors are not recorded (allow_error=True).
58def eval_tcp_server(grade: Grade0, machine_name: str, server_name: str, 59 step: int = 1) -> list[int] | None: 60 """Check that a process matching server_name is running on machine_name and 61 return the TCP ports it listens on. 62 63 Args: 64 grade: the Grade0 instance. 65 machine_name: name of the virtual machine to inspect. 66 server_name: substring to match against running process names (ps output). 67 step: step number passed to grade.test() (default: 1). 68 69 Returns: 70 A list of TCP port numbers in LISTEN state used by the process, 71 or None if no matching process is found. 72 """ 73 # All grade.test() calls must be made unconditionally so they are registered 74 # in the first (registration) pass and carry real results in the second pass. 75 pids_out, pids_code = grade.test( 76 machine_name=machine_name, 77 command=f"pgrep -f {server_name}", 78 step=step, 79 ) 80 ss_out, _ = grade.test( 81 machine_name=machine_name, 82 command=f"ss -tlnp", 83 step=step, 84 ) 85 86 if pids_code != 0: 87 return None 88 89 pids = set(pids_out.split()) 90 if not pids: 91 return None 92 93 ports = [] 94 for line in ss_out.splitlines(): 95 # ss -tlnp output has a "users:(("name",pid=NNN,...))" field 96 if "LISTEN" not in line: 97 continue 98 if not any(f"pid={pid}" in line for pid in pids): 99 continue 100 # Extract port from the local address column: 0.0.0.0:443, 127.0.0.1:53, 101 # 127.0.0.53%lo:53, *:443 (dual-stack socket), [::]:443, [::1]:9000, [fd00::1%eth0]:80 102 addr_m = re.search(r'\s+(?:\*|\[[^\]]*\](?:%\w+)?|[0-9a-fA-F.:]+(?:%\w+)?):(\d+)\s+', line) 103 if addr_m: 104 ports.append(int(addr_m.group(1))) 105 106 return sorted(set(ports))
Check that a process matching server_name is running on machine_name and return the TCP ports it listens on.
Arguments:
- grade: the Grade0 instance.
- machine_name: name of the virtual machine to inspect.
- server_name: substring to match against running process names (ps output).
- step: step number passed to grade.test() (default: 1).
Returns:
A list of TCP port numbers in LISTEN state used by the process, or None if no matching process is found.
109def transplant_files(grade: Grade0, src: str, dst: str, files: dict[str, str], 110 download_step: int = 1, apply_step: int = 2, 111 workdir: str = "/tmp/sre_transplant") -> dict[str, str]: 112 """Copy text files from the container *src* to the container *dst* during an evaluation. 113 114 *files* maps a path on *src* to a path on *dst* (normally under *workdir*). Each file is 115 read on *src* at *download_step* (``base64 -w0``) and, from the grade pass that follows 116 that step, re-created on *dst* at *apply_step* (``base64 -d``, mode 0600) — the two-step 117 contract of ``firewall.transplant_ruleset``: nothing is registered on *dst* for an empty 118 or missing file, so the command keys stay stable and no phantom "write of the empty 119 string" exists. *workdir* is wiped and re-created on *dst* at *download_step*, so a file 120 of a previous evaluation never survives. 121 122 Returns ``{src_path: content}`` with the decoded text of each file (``''`` until the 123 download step has run, or when the file is missing on *src*). 124 """ 125 grade.test(dst, f"rm -rf {shlex.quote(workdir)}; mkdir -p {shlex.quote(workdir)}", 126 step=download_step, allow_error=True) 127 contents: dict[str, str] = {} 128 for src_path, dst_path in files.items(): 129 b64, code = grade.test(src, f"base64 -w0 {shlex.quote(src_path)} 2>/dev/null", 130 step=download_step, allow_error=True) 131 b64 = (b64 or "").strip() 132 if code != 0 or not b64: 133 contents[src_path] = "" 134 continue 135 try: 136 contents[src_path] = base64.b64decode(b64, validate=True).decode("utf-8", errors="replace") 137 except (ValueError, UnicodeDecodeError): 138 contents[src_path] = "" 139 continue 140 parent = posixpath.dirname(dst_path) or "." 141 grade.test(dst, f"mkdir -p {shlex.quote(parent)} && echo {b64} | base64 -d > {shlex.quote(dst_path)}" 142 f" && chmod 600 {shlex.quote(dst_path)}", 143 step=apply_step, allow_error=True) 144 return contents
Copy text files from the container src to the container dst during an evaluation.
files maps a path on src to a path on dst (normally under workdir). Each file is
read on src at download_step (base64 -w0) and, from the grade pass that follows
that step, re-created on dst at apply_step (base64 -d, mode 0600) — the two-step
contract of firewall.transplant_ruleset: nothing is registered on dst for an empty
or missing file, so the command keys stay stable and no phantom "write of the empty
string" exists. workdir is wiped and re-created on dst at download_step, so a file
of a previous evaluation never survives.
Returns {src_path: content} with the decoded text of each file ('' until the
download step has run, or when the file is missing on src).