# -* coding: utf-8 # SPDX-FileCopyrightText: 2022 Alex # SPDX-License-Identifier: LGPL-3.0-only import os, re, shutil, socket, subprocess, time from os import path class NaxsiTestException(Exception): pass def is_posix(): return "posix" == os.name def get_naxsi_dir(): python_dir = path.dirname(__file__) unit_tests_dir = path.dirname(python_dir) return path.dirname(unit_tests_dir) def get_nginx_dir(): naxsi_dir = get_naxsi_dir() return path.join(naxsi_dir, "nginx-tmp") def get_nginx_pid_path(): nginx_dir = get_nginx_dir() if is_posix(): return path.join(nginx_dir, "naxsi_ut", "nginx.pid") else: return path.join(nginx_dir, "logs", "nginx.pid") def get_naxsi_module_so_path(): nginx_dir = get_nginx_dir() return path.join(nginx_dir, "naxsi_ut", "modules", "ngx_http_naxsi_module.so") def get_naxsi_rules_path(): naxsi_dir = get_naxsi_dir() return path.join(naxsi_dir, "naxsi_rules", "naxsi_core.rules") def get_naxsi_blocking_rules_dir(): naxsi_dir = get_naxsi_dir() return path.join(naxsi_dir, "naxsi_rules", "blocking") def get_naxsi_whitelists_rules_dir(): naxsi_dir = get_naxsi_dir() return path.join(naxsi_dir, "naxsi_rules", "whitelists") def get_error_log_path(): nginx_dir = get_nginx_dir() if is_posix(): log_dir = path.join(nginx_dir, "naxsi_ut") else: log_dir = path.join(nginx_dir, "logs") return path.join(log_dir, "error.log") def format_rules_list(rules_dir): names = os.listdir(rules_dir) paths = list(map(lambda n: path.join(rules_dir, n).replace("\\", "/"), names)) includes = list(map(lambda p: "include {};".format(p), paths)); return "\n".join(includes) def write_nginx_conf(port, http_config, config): nginx_dir = get_nginx_dir().replace("\\", "/") main_config = "" if is_posix(): naxsi_module_so = get_naxsi_module_so_path() main_config = "load_module {};".format(naxsi_module_so) naxsi_rules = get_naxsi_rules_path().replace("\\", "/") http_config = http_config.replace("$TEST_NGINX_NAXSI_RULES", naxsi_rules) blocking_rules = format_rules_list(get_naxsi_blocking_rules_dir()) http_config = http_config.replace("include $TEST_NGINX_NAXSI_BLOCKING_RULES/*;", blocking_rules) config = config.replace("$TEST_NGINX_SERVROOT", nginx_dir) whitelists_rules = format_rules_list(get_naxsi_whitelists_rules_dir()) config = config.replace("include $TEST_NGINX_NAXSI_WHITELISTS_RULES/*;", whitelists_rules) conf = """ worker_processes 1; %s events { worker_connections 1024; } http { %s server { listen %s; server_name localhost; %s } } """ % (main_config, http_config, port, config) conf_dir = "naxsi_ut" if is_posix() else "conf" nginx_conf = path.join(nginx_dir, conf_dir, "nginx.conf") with open(nginx_conf, "w", encoding="utf-8") as file: file.write(conf) def start_nginx(): nginx_dir = get_nginx_dir() if is_posix(): nginx_exe = path.join(nginx_dir, "sbin", "nginx") else: nginx_exe = path.join(nginx_dir, "nginx.exe") if not path.isfile(nginx_exe): raise NaxsiTestException("Nginx executable not found, directory: [{}]".format(nginx_dir)) return subprocess.Popen([nginx_exe], cwd=nginx_dir) def get_children_pids(parent_pid): res = [] children_pids_text = subprocess.run(["/usr/bin/ps", "-o", "pid", "--ppid", str(parent_pid)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout.decode("utf-8") for child_pid_text in children_pids_text.split("\n")[1:-1]: child_pid = int(child_pid_text.strip()) res.append(child_pid) #grandchildren_pids = get_children_pids(child_pid) #res.extend(grandchildren_pids) return res def read_pid_from_file(nginx_pid_path): if not path.isfile(nginx_pid_path): return None with open(nginx_pid_path, encoding="utf-8") as file: return int(file.read().strip()) def kill_nginx(proc=None): nginx_pid_path = get_nginx_pid_path() if is_posix() or proc is None: master_pid = read_pid_from_file(nginx_pid_path) else: master_pid = proc.pid if master_pid is None: return try: if is_posix(): worker_pid_list = get_children_pids(master_pid) pid_list = [master_pid] pid_list.extend(worker_pid_list) for pid in pid_list: subprocess.run(["/usr/bin/kill", "-9", str(pid)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) for pid in pid_list: subprocess.run(["/usr/bin/tail", "--pid={}".format(pid), "-f", "/dev/null"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) else: subprocess.run(["c:/windows/system32/taskkill.exe", "/f", "/t", "/pid", str(proc.pid)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if proc is not None: proc.wait() finally: if path.isfile(nginx_pid_path): os.remove(nginx_pid_path) def read_headers(sock, buf): view = memoryview(buf) write_pos = 0 for _ in range(32): read = sock.recv_into(view[write_pos:]) write_pos += read for i in range(3, write_pos): if b"\r\n\r\n" == buf[i - 3:i + 1]: return i + 1, write_pos time.sleep(0.1) raise NaxsiTestException("Headers read failed, read: [{}]".format(buf[:write_pos])) def parse_headers(buf, data_begin_pos): lines = buf[:data_begin_pos].split(b"\r\n") status = int(lines[0].split(b" ")[1]) headers = {} for ln in lines[1:-2]: parts = ln.decode("utf-8").split(": ") headers[parts[0]] = parts[1] return status, headers def read_response(sock): buf = bytearray(4096) data_begin_pos, write_pos = read_headers(sock, buf) status, headers = parse_headers(buf, data_begin_pos) clen = int(headers["Content-Length"]) buffered = write_pos - data_begin_pos remaining = clen - buffered if remaining > 0: view = memoryview(buf) for _ in range(32): read = sock.recv_into(view[write_pos:]) write_pos += read remaining -= read if remaining <= 0: break time.sleep(0.1) if remaining > 0: raise NaxsiTestException("Body read failed, read: [{}]".format(buf[:write_pos])) body = bytes(buf[data_begin_pos:data_begin_pos + clen]) return status, headers, body def includes_header(headers, name): lname = name.lower() filtered = list(filter(lambda n: n.lower() == lname, headers.keys())) return len(filtered) > 0 def send_request(port, url_path, method, headers, data): header_lines = [ "{} {} HTTP/1.1".format(method, url_path), ] if not includes_header(headers, "Host"): header_lines.append("Host: 127.0.0.1:{}".format(port)) if not includes_header(headers, "User-Agent"): header_lines.append("User-Agent: Mozilla/5.0 (iPad; U; CPU OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B334b Safari/531.21.10") for name, val in headers.items(): header_lines.append("{}: {}".format(name, val)) if data is not None and len(data) > 0 and not includes_header(headers, "Content-Length"): header_lines.append("Content-Length: {}".format(len(data))) header_lines.extend(["", ""]) req_bytes = "\r\n".join(header_lines).encode("utf-8") if data is not None: req_bytes += data return send_raw_request(port, req_bytes) def send_raw_request(port, data): for _ in range(4): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect(("127.0.0.1", port)) sock.sendall(data) status, headers, body = read_response(sock) return status, headers, body except (ConnectionRefusedError, ConnectionResetError): time.sleep(0.1) raise NaxsiTestException("Connection failed, port: [{}]".format(port)) def send_curl(port, url_path, method, headers, data, curl_protocol, curl_options): if is_posix(): curl_exe = ["curl"] else: curl_exe = ["curl.exe"] curl_exe.extend(['-i', '-H', 'User-Agent:', '-H', 'Accept:', '-sS']) if curl_options is not None and len(curl_options) > 0: curl_exe.extend(curl_options.split()) if not includes_header(headers, "Host"): curl_exe.extend(['-H', "'Host: localhost'"]) for name, val in headers.items(): curl_exe.extend(['-H', "'{}: {}'".format(name, val)]) if data is not None and len(data) > 0 and not includes_header(headers, "Content-Length"): curl_exe.extend(['-H', "'Content-Length: {}'".format(len(data))]) if data is not None and len(data) > 0: curl_exe.extend(['-d', "'{}'".format(data)]) if method is not None and len(method) > 0: if "HEAD" == method: curl_exe.append('-I') elif "GET" != method: curl_exe.extend(['-X', "'{}'".format(method)]) curl_exe.append('{}://127.0.0.1:{}{}'.format(curl_protocol, port, url_path)) # print(curl_exe) # print(" ".join(curl_exe)) curl_proc = subprocess.Popen(curl_exe, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8") curl_proc.wait() try: outs, errs = curl_proc.communicate(timeout=15) except TimeoutExpired: curl_proc.kill() outs, errs = curl_proc.communicate() if outs is None or len(outs) == 0: if errs is not None and len(errs) > 0: raise NaxsiTestException('ERROR: curl command generates stderr output: "{}"'.format(errs)) raise NaxsiTestException('ERROR: curl command generates no stdout output') if errs is not None and len(errs) > 0: print('WARNING: curl command generates stderr output: "{}"'.format(errs)) crlf = "\n" data_begin_pos = 0 for i in range(len(crlf)*2, len(outs)): if crlf+crlf == outs[i - len(crlf)*2:i]: data_begin_pos = i break lines = outs[:data_begin_pos].split(crlf) status = int(lines[0].split(" ")[1]) out_headers = {} for ln in lines[1:-2]: parts = ln.split(": ") out_headers[parts[0]] = parts[1] return status, out_headers, outs[data_begin_pos:] def write_user_files(files): nginx_dir = get_nginx_dir() for name, value in files.items(): filepath = path.join(nginx_dir, "html", name) if name != path.basename(name): os.makedirs(path.dirname(filepath)) with open(filepath, "w", encoding="utf-8") as file: file.write(value) def delete_user_files(files): nginx_dir = get_nginx_dir() for name in files.keys(): filepath = path.join(nginx_dir, "html", name) if name == path.basename(name): os.remove(filepath) else: shutil.rmtree(path.dirname(filepath)) def delete_error_log(): error_log = get_error_log_path() if path.exists(error_log): os.remove(error_log) def error_log_matches_re(regexes): error_log = get_error_log_path() with open(error_log, encoding="utf-8") as file: lines = file.readlines() filtered = list(filter(lambda l: " NAXSI_" in l or " {\"" in l, lines)) for rg in regexes: for fl in filtered: if rg.match(fl) is not None: return True return False class nginx_runner(): def __init__(self, http_config="", config="", port=8080, user_files={}, ): self.port = port self.http_config = http_config self.config = config self.user_files = user_files self.proc = None def __enter__(self): kill_nginx(self.proc) write_nginx_conf(self.port, self.http_config, self.config) write_user_files(self.user_files) delete_error_log() self.proc = start_nginx() return self def __exit__(self, exc_type, exc_value, traceback): if self.proc is not None: kill_nginx(self.proc) delete_user_files(self.user_files) def request(self, url, method="GET", headers={}, data=None, resp_body_required=False, curl=False, curl_protocol="http", curl_options=""): if data is not None and type(data) == str: data = data.encode("utf-8") if not curl: status, _, body = send_request(self.port, url, method, headers, data) else: status, _, body = send_curl(self.port, url, method, headers, data, curl_protocol, curl_options) if resp_body_required: return status, body else: return status def raw_request(self, data): if data is not None and type(data) == str: data = data.encode("utf-8") if data.startswith(b"\n"): data = data[1:] data = data.replace(b"\\r", b"\r") status, _, _ = send_raw_request(self.port, data) return status def error_log_matches(self, lst): regexes = list(map(lambda st: re.compile(r"^.*" + st + r".*$"), lst)) return error_log_matches_re(regexes)