From d6e833803a2e470af199f33270a79c8bd986f9cd Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Tue, 21 Feb 2023 02:52:11 +0000 Subject: [PATCH 01/58] Add 'protocol' to challenge-example.yml --- ctfcli/spec/challenge-example.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ctfcli/spec/challenge-example.yml b/ctfcli/spec/challenge-example.yml index a4b3989..62a92f7 100644 --- a/ctfcli/spec/challenge-example.yml +++ b/ctfcli/spec/challenge-example.yml @@ -24,6 +24,12 @@ type: standard # If you have an imaged hosted on Docker set to the image url (e.g. python/3.8:latest, registry.gitlab.com/python/3.8:latest) # Follow Docker best practices and assign a tag image: null + +# Specify a protocol that should be used to connect to the running image +# For example if the image is a website you can specify http or https +# Otherwise you can specify tcp +protocol: null + # Specify a host to deploy the challenge onto. # The currently supported URI schemes are ssh:// and registry:// # ssh is an ssh URI where the above image will be copied to and deployed (e.g. ssh://root@123.123.123.123) From 78881df5fd57cfb3d80710c3d996a9f211ec6c98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Mar 2023 18:47:26 -0500 Subject: [PATCH 02/58] Bump certifi from 2022.9.24 to 2022.12.7 (#108) Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.9.24 to 2022.12.7. - [Release notes](https://github.com/certifi/python-certifi/releases) - [Commits](https://github.com/certifi/python-certifi/compare/2022.09.24...2022.12.07) --- updated-dependencies: - dependency-name: certifi dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92d4357..c61bc62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ arrow==1.2.3 # via jinja2-time binaryornot==0.4.4 # via cookiecutter -certifi==2022.9.24 +certifi==2022.12.7 # via requests chardet==5.0.0 # via binaryornot From 45ffa11f613feb6d279c01fee4c83b5f6fd074e7 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 11 Mar 2023 16:32:45 -0500 Subject: [PATCH 03/58] Replace flake8 with ruff (#112) * Replace flake8 with ruff --- Makefile | 2 +- ctfcli/cli/challenges.py | 12 ++++++------ ctfcli/cli/pages.py | 5 +++-- ctfcli/cli/plugins.py | 2 +- ctfcli/utils/challenge.py | 32 ++++++++++++++++---------------- ctfcli/utils/pages.py | 4 ++-- development.txt | 5 +---- 7 files changed, 30 insertions(+), 32 deletions(-) diff --git a/Makefile b/Makefile index 711704d..4401321 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ lint: - flake8 --ignore=E402,E501,E712,W503,E203,I002 --exclude=ctfcli/templates **/*.py + ruff check --ignore=E402,E501,E712,I002 --exclude=ctfcli/templates --exclude=build . black --check --exclude=ctfcli/templates . format: diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index fd960cb..b2e9c97 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -173,7 +173,7 @@ def install(self, challenge=None, force=False, ignore=()): else: # If we don't break because of duplicated challenge names click.secho(f'Installing {challenge["name"]}', fg="yellow") create_challenge(challenge=challenge, ignore=ignore) - click.secho(f"Success!", fg="green") + click.secho("Success!", fg="green") def sync(self, challenge=None, ignore=()): if challenge is None: @@ -209,7 +209,7 @@ def sync(self, challenge=None, ignore=()): click.secho(f'Syncing {challenge["name"]}', fg="yellow") sync_challenge(challenge=challenge, ignore=ignore) - click.secho(f"Success!", fg="green") + click.secho("Success!", fg="green") def update(self, challenge=None): config = load_config() @@ -339,7 +339,7 @@ def deploy(self, challenge, host=None): ) else: click.secho( - f"An error occured during deployment", fg="red", + "An error occured during deployment", fg="red", ) def push(self, challenge=None): @@ -364,7 +364,7 @@ def push(self, challenge=None): def healthcheck(self, challenge): config = load_config() - challenges = config["challenges"] + _challenges = config["challenges"] # challenge_path = challenges[challenge] path = Path(challenge) @@ -406,11 +406,11 @@ def healthcheck(self, challenge): if rcode != 0: click.secho( - f"Healcheck failed", fg="red", + "Healcheck failed", fg="red", ) sys.exit(1) else: click.secho( - f"Success", fg="green", + "Success", fg="green", ) sys.exit(0) diff --git a/ctfcli/cli/pages.py b/ctfcli/cli/pages.py index 8d94005..f4a0c7d 100644 --- a/ctfcli/cli/pages.py +++ b/ctfcli/cli/pages.py @@ -17,14 +17,15 @@ class Pages(object): def install(self): try: _config = load_config() - except: + except Exception as e: + print(e) click.secho("No ctfcli configuration found", fg="red") sys.exit(1) pages = Path("./pages") if pages.is_dir() is False: click.secho( - f'"pages" folder not found. All pages must exist in the "pages" folder.', + '"pages" folder not found. All pages must exist in the "pages" folder.', fg="red", ) sys.exit(1) diff --git a/ctfcli/cli/plugins.py b/ctfcli/cli/plugins.py index e07c0a2..494506d 100644 --- a/ctfcli/cli/plugins.py +++ b/ctfcli/cli/plugins.py @@ -18,7 +18,7 @@ def install(self, url): pip3 = shutil.which("pip3") if pip is None and pip3 is None: - click.secho(f"Neither pip nor pip3 was found, is it in the PATH?", fg="red") + click.secho("Neither pip nor pip3 was found, is it in the PATH?", fg="red") return if pip is None: diff --git a/ctfcli/utils/challenge.py b/ctfcli/utils/challenge.py index 7094fd5..7878244 100644 --- a/ctfcli/utils/challenge.py +++ b/ctfcli/utils/challenge.py @@ -79,7 +79,7 @@ def sync_challenge(challenge, ignore=[]): # Create new flags if challenge.get("flags") and "flags" not in ignore: # Delete existing flags - current_flags = s.get(f"/api/v1/flags", json=data).json()["data"] + current_flags = s.get("/api/v1/flags", json=data).json()["data"] for flag in current_flags: if flag["challenge_id"] == challenge_id: flag_id = flag["id"] @@ -88,11 +88,11 @@ def sync_challenge(challenge, ignore=[]): for flag in challenge["flags"]: if type(flag) == str: data = {"content": flag, "type": "static", "challenge_id": challenge_id} - r = s.post(f"/api/v1/flags", json=data) + r = s.post("/api/v1/flags", json=data) r.raise_for_status() elif type(flag) == dict: flag["challenge_id"] = challenge_id - r = s.post(f"/api/v1/flags", json=flag) + r = s.post("/api/v1/flags", json=flag) r.raise_for_status() # Update topics @@ -110,7 +110,7 @@ def sync_challenge(challenge, ignore=[]): # Add new challenge topics for topic in challenge["topics"]: r = s.post( - f"/api/v1/topics", + "/api/v1/topics", json={ "value": topic, "type": "challenge", @@ -122,7 +122,7 @@ def sync_challenge(challenge, ignore=[]): # Update tags if challenge.get("tags") and "tags" not in ignore: # Delete existing tags - current_tags = s.get(f"/api/v1/tags", json=data).json()["data"] + current_tags = s.get("/api/v1/tags", json=data).json()["data"] for tag in current_tags: if tag["challenge_id"] == challenge_id: tag_id = tag["id"] @@ -130,14 +130,14 @@ def sync_challenge(challenge, ignore=[]): r.raise_for_status() for tag in challenge["tags"]: r = s.post( - f"/api/v1/tags", json={"challenge_id": challenge_id, "value": tag} + "/api/v1/tags", json={"challenge_id": challenge_id, "value": tag} ) r.raise_for_status() # Upload files if challenge.get("files") and "files" not in ignore: # Delete existing files - all_current_files = s.get(f"/api/v1/files?type=challenge", json=data).json()[ + all_current_files = s.get("/api/v1/files?type=challenge", json=data).json()[ "data" ] for f in all_current_files: @@ -158,13 +158,13 @@ def sync_challenge(challenge, ignore=[]): data = {"challenge_id": challenge_id, "type": "challenge"} # Specifically use data= here instead of json= to send multipart/form-data - r = s.post(f"/api/v1/files", files=files, data=data) + r = s.post("/api/v1/files", files=files, data=data) r.raise_for_status() # Create hints if challenge.get("hints") and "hints" not in ignore: # Delete existing hints - current_hints = s.get(f"/api/v1/hints", json=data).json()["data"] + current_hints = s.get("/api/v1/hints", json=data).json()["data"] for hint in current_hints: if hint["challenge_id"] == challenge_id: hint_id = hint["id"] @@ -181,7 +181,7 @@ def sync_challenge(challenge, ignore=[]): "challenge_id": challenge_id, } - r = s.post(f"/api/v1/hints", json=data) + r = s.post("/api/v1/hints", json=data) r.raise_for_status() # Update requirements @@ -246,18 +246,18 @@ def create_challenge(challenge, ignore=[]): for flag in challenge["flags"]: if type(flag) == str: data = {"content": flag, "type": "static", "challenge_id": challenge_id} - r = s.post(f"/api/v1/flags", json=data) + r = s.post("/api/v1/flags", json=data) r.raise_for_status() elif type(flag) == dict: flag["challenge"] = challenge_id - r = s.post(f"/api/v1/flags", json=flag) + r = s.post("/api/v1/flags", json=flag) r.raise_for_status() # Create topics if challenge.get("topics") and "topics" not in ignore: for topic in challenge["topics"]: r = s.post( - f"/api/v1/topics", + "/api/v1/topics", json={ "value": topic, "type": "challenge", @@ -270,7 +270,7 @@ def create_challenge(challenge, ignore=[]): if challenge.get("tags") and "tags" not in ignore: for tag in challenge["tags"]: r = s.post( - f"/api/v1/tags", json={"challenge_id": challenge_id, "value": tag} + "/api/v1/tags", json={"challenge_id": challenge_id, "value": tag} ) r.raise_for_status() @@ -288,7 +288,7 @@ def create_challenge(challenge, ignore=[]): data = {"challenge_id": challenge_id, "type": "challenge"} # Specifically use data= here instead of json= to send multipart/form-data - r = s.post(f"/api/v1/files", files=files, data=data) + r = s.post("/api/v1/files", files=files, data=data) r.raise_for_status() # Add hints @@ -303,7 +303,7 @@ def create_challenge(challenge, ignore=[]): "challenge_id": challenge_id, } - r = s.post(f"/api/v1/hints", json=data) + r = s.post("/api/v1/hints", json=data) r.raise_for_status() # Add requirements diff --git a/ctfcli/utils/pages.py b/ctfcli/utils/pages.py index 6ddd568..a1a8d4f 100644 --- a/ctfcli/utils/pages.py +++ b/ctfcli/utils/pages.py @@ -9,7 +9,7 @@ def get_current_pages(): s = generate_session() - return s.get(f"/api/v1/pages", json=True).json()["data"] + return s.get("/api/v1/pages", json=True).json()["data"] def get_existing_page(route, pageset=None): @@ -67,5 +67,5 @@ def install_page(matter, path_obj): "auth_required": auth_required, "format": format, } - r = s.post(f"/api/v1/pages", json=data) + r = s.post("/api/v1/pages", json=data) r.raise_for_status() diff --git a/development.txt b/development.txt index e834895..2a2c786 100644 --- a/development.txt +++ b/development.txt @@ -3,8 +3,5 @@ twine==3.1.1 setuptools==46.1.3 wheel==0.34.2 black==19.10b0 -flake8==3.7.9 -flake8-bugbear==20.1.2 -flake8-comprehensions==3.1.4 isort==4.3.21 -flake8-isort==2.8.0 \ No newline at end of file +ruff==0.0.254 From cdabe540973cda4fa57b465352abd0ecb0fc0c88 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Tue, 14 Mar 2023 18:18:16 -0400 Subject: [PATCH 04/58] Challenge Deploy v2 (#113) * Add cloud deploy for hosted CTFd instances * Further define what other deployment methods should provide & return --- ctfcli/cli/challenges.py | 61 +++++++++++++----- ctfcli/utils/deploy.py | 132 ++++++++++++++++++++++++++++++++++++--- ctfcli/utils/git.py | 2 + ctfcli/utils/images.py | 24 ++++--- 4 files changed, 184 insertions(+), 35 deletions(-) diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index b2e9c97..b6fad3d 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -22,10 +22,10 @@ get_project_path, load_config, ) -from ctfcli.utils.deploy import DEPLOY_HANDLERS +from ctfcli.utils.git import get_git_repo_head_branch from ctfcli.utils.spec import CHALLENGE_SPEC_DOCS, blank_challenge_spec from ctfcli.utils.templates import get_template_dir -from ctfcli.utils.git import get_git_repo_head_branch +from ctfcli.utils.deploy import DEPLOY_HANDLERS class Challenge(object): @@ -296,7 +296,7 @@ def lint(self, challenge=None): lint_challenge(path) - def deploy(self, challenge, host=None): + def deploy(self, challenge, host=None, protocol=None): if challenge is None: challenge = os.getcwd() @@ -307,35 +307,62 @@ def deploy(self, challenge, host=None): challenge = load_challenge(path) image = challenge.get("image") - target_host = host or challenge.get("host") or input("Target host URI: ") if image is None: click.secho( "This challenge can't be deployed because it doesn't have an associated image", fg="red", ) return + + target_host = host or challenge.get("host") if bool(target_host) is False: + # If we do not have a host we should set to cloud click.secho( - "This challenge can't be deployed because there is no target host to deploy to", - fg="red", + "No host specified, defaulting to cloud deployment", fg="yellow", ) - return - url = urlparse(target_host) + scheme = "cloud" + else: + url = urlparse(target_host) + if bool(url.netloc) is False: + click.secho( + "Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://", + fg="red", + ) + return + scheme = url.scheme - if bool(url.netloc) is False: - click.secho( - "Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://", - fg="red", - ) - return + protocol = protocol or challenge.get("protocol") - status, domain, port = DEPLOY_HANDLERS[url.scheme]( - challenge=challenge, host=target_host + status, domain, port, connect_info = DEPLOY_HANDLERS[scheme]( + challenge=challenge, host=target_host, protocol=protocol, ) + challenge["connection_info"] = connect_info + if status: + # Search for challenge + installed_challenges = load_installed_challenges() + for c in installed_challenges: + # Sync challenge if it already exists + if c["name"] == challenge["name"]: + sync_challenge( + challenge, + ignore=[ + "flags", + "topics", + "tags", + "files", + "hints", + "requirements", + ], + ) + break + else: + # Install challenge + create_challenge(challenge=challenge) + click.secho( - f"Challenge deployed at {domain}:{port}", fg="green", + f"Challenge deployed at {challenge['connection_info']}", fg="green", ) else: click.secho( diff --git a/ctfcli/utils/deploy.py b/ctfcli/utils/deploy.py index 2945fe2..087874c 100644 --- a/ctfcli/utils/deploy.py +++ b/ctfcli/utils/deploy.py @@ -1,12 +1,34 @@ import os import subprocess +import time +import click from pathlib import Path from urllib.parse import urlparse +from slugify import slugify +from ctfcli.utils.config import generate_session -from ctfcli.utils.images import build_image, export_image, get_exposed_ports +from ctfcli.utils.images import ( + build_image, + export_image, + get_exposed_ports, + push_image, +) -def ssh(challenge, host): +def format_connection_info(protocol, hostname, tcp_hostname, tcp_port): + if protocol is None: + connection_info = hostname + elif protocol.startswith("http"): + connection_info = f"{protocol}://{hostname}" + elif protocol == "tcp": + connection_info = f"nc {tcp_hostname} {tcp_port}" + else: + connection_info = hostname + + return connection_info + + +def ssh(challenge, host, protocol): # Build image image_name = build_image(challenge=challenge) print(f"Built {image_name}") @@ -39,17 +61,111 @@ def ssh(challenge, host): os.remove(image_path) print(f"Cleaned up {image_path}") - return True, domain, exposed_port + status = True + domain = domain + port = exposed_port + connect_info = format_connection_info( + protocol=protocol, hostname=domain, tcp_hostname=domain, tcp_port=port, + ) + return status, domain, port, connect_info -def registry(challenge, host): +def registry(challenge, host, protocol): # Build image image_name = build_image(challenge=challenge) - print(f"Built {image_name}") url = urlparse(host) tag = f"{url.netloc}{url.path}" - subprocess.call(["docker", "tag", image_name, tag]) - subprocess.call(["docker", "push", tag]) + push_image(local_tag=image_name, location=tag) + status = True + domain = "" + port = "" + connect_info = format_connection_info( + protocol=protocol, hostname=domain, tcp_hostname=domain, tcp_port=port, + ) + return status, domain, port, connect_info + + +def cloud(challenge, host, protocol): + name = challenge["name"] + slug = slugify(name) + + s = generate_session() + # Detect whether we have the appropriate endpoints + check = s.get("/api/v1/images", json=True) + if check.ok is False: + click.secho( + "Target instance does not have deployment endpoints", fg="red", + ) + return False, None, None, None + + # Try to find an appropriate image. + images = s.get("/api/v1/images", json=True).json()["data"] + image = None + for i in images: + if i["location"].endswith(f"/{slug}"): + image = i + break + else: + # Create the image if we did not find it. + image = s.post("/api/v1/images", json={"name": slug}).json()["data"] + + # Build image + image_name = build_image(challenge=challenge) + location = image["location"] + + # TODO: Authenticate to Registry + + # Push image + push_image(image_name, location) + + # Look for existing service + services = s.get("/api/v1/services", json=True).json()["data"] + service = None + for srv in services: + if srv["name"] == slug: + service = srv + # Update the service + s.patch( + f"/api/v1/services/{service['id']}", json={"image": location} + ).raise_for_status() + service = s.get(f"/api/v1/services/{service['id']}", json=True).json()[ + "data" + ] + break + else: + # Could not find the service. Create it using our pushed image. + # Deploy the image by creating service + service = s.post( + "/api/v1/services", json={"name": slug, "image": location,} + ).json()["data"] + + # Get connection details + service_id = service["id"] + service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] + + while service["hostname"] is None: + click.secho( + "Waiting for challenge hostname", fg="yellow", + ) + service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] + time.sleep(10) + + # Expose port if we are using tcp + if protocol == "tcp": + service = s.patch(f"/api/v1/services/{service['id']}", json={"expose": True}) + service.raise_for_status() + service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] + + status = True + domain = "" + port = "" + connect_info = format_connection_info( + protocol=protocol, + hostname=service["hostname"], + tcp_hostname=service["tcp_hostname"], + tcp_port=service["tcp_port"], + ) + return status, domain, port, connect_info -DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry} +DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry, "cloud": cloud} diff --git a/ctfcli/utils/git.py b/ctfcli/utils/git.py index d29640f..29a438d 100644 --- a/ctfcli/utils/git.py +++ b/ctfcli/utils/git.py @@ -10,6 +10,8 @@ def get_git_repo_head_branch(repo): ["git", "ls-remote", "--symref", repo, "HEAD"] ).decode() head_branch = out.split()[1] + if head_branch.startswith("refs/heads/"): + head_branch = head_branch[11:] return head_branch diff --git a/ctfcli/utils/images.py b/ctfcli/utils/images.py index 1b1e666..c06649c 100644 --- a/ctfcli/utils/images.py +++ b/ctfcli/utils/images.py @@ -2,33 +2,37 @@ import subprocess import tempfile from pathlib import Path +from slugify import slugify -def sanitize_name(name): - """ - Function to sanitize names to docker safe image names - TODO: Good enough but probably needs to be more conformant with docker - """ - return name.lower().replace(" ", "-") +def login_registry(host, username, password): + subprocess.call(["docker", "login", "-u", username, "-p"], password, host) def build_image(challenge): - name = sanitize_name(challenge["name"]) - path = Path(challenge.file_path).parent.absolute() + name = slugify(challenge["name"]) + path = Path(challenge.file_path).parent.absolute() / challenge["image"] print(f"Building {name} from {path}") subprocess.call(["docker", "build", "-t", name, "."], cwd=path) + print(f"Built {name}") return name +def push_image(local_tag, location): + print(f"Pushing {local_tag} to {location}") + subprocess.call(["docker", "tag", local_tag, location]) + subprocess.call(["docker", "push", location]) + + def export_image(challenge): - name = sanitize_name(challenge["name"]) + name = slugify(challenge["name"]) temp = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{name}.docker.tar") subprocess.call(["docker", "save", "--output", temp.name, name]) return temp.name def get_exposed_ports(challenge): - image_name = sanitize_name(challenge["name"]) + image_name = slugify(challenge["name"]) output = subprocess.check_output( ["docker", "inspect", "--format={{json .Config.ExposedPorts }}", image_name,] ) From 31c92d0549d63c2aef458770e0b24e60e1d7f4dd Mon Sep 17 00:00:00 2001 From: Tom Plant <21111317+pl4nty@users.noreply.github.com> Date: Tue, 4 Apr 2023 15:51:02 +1000 Subject: [PATCH 05/58] Add timeout to cloud challenge deployment (#116) --- ctfcli/utils/deploy.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ctfcli/utils/deploy.py b/ctfcli/utils/deploy.py index 087874c..a6688d9 100644 --- a/ctfcli/utils/deploy.py +++ b/ctfcli/utils/deploy.py @@ -143,13 +143,21 @@ def cloud(challenge, host, protocol): service_id = service["id"] service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] - while service["hostname"] is None: + DEPLOY_TIMEOUT = 180 + while service["hostname"] is None and DEPLOY_TIMEOUT > 0: click.secho( "Waiting for challenge hostname", fg="yellow", ) service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] + DEPLOY_TIMEOUT -= 10 time.sleep(10) + if DEPLOY_TIMEOUT == 0: + click.secho( + "Timeout waiting for challenge to deploy", fg="red", + ) + return False, None, None, None + # Expose port if we are using tcp if protocol == "tcp": service = s.patch(f"/api/v1/services/{service['id']}", json={"expose": True}) From bbcbfd00c31a72cb3c83fcfca340dd705d05245b Mon Sep 17 00:00:00 2001 From: Tom Plant <21111317+pl4nty@users.noreply.github.com> Date: Thu, 18 May 2023 00:44:17 +1000 Subject: [PATCH 06/58] Add configurable cookies via config file (#114) * Add configurable cookies via config file --------- Co-authored-by: Kevin Chung --- ctfcli/utils/config.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ctfcli/utils/config.py b/ctfcli/utils/config.py index 207f1ba..b874e55 100644 --- a/ctfcli/utils/config.py +++ b/ctfcli/utils/config.py @@ -80,4 +80,9 @@ def generate_session(): s = APISession(prefix_url=url) s.verify = ssl_verify s.headers.update({"Authorization": f"Token {access_token}"}) + + # Handle cookies section in config + if "cookies" in config: + s.cookies.update(dict(config["cookies"])) + return s From de3dcee5aef90d2b3a02f2625a623e3e6914903d Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sun, 25 Jun 2023 19:53:52 -0400 Subject: [PATCH 07/58] Mark 0.0.12 (#119) # 0.0.12 / 2023-06-25 ### Added - Add cloud deploy for hosted CTFd instances - Add the `protocol` field in the challenge.yml spec - Further define what other deployment methods should provide & return - Add the ability to add HTTP cookies to ctfcli requests via the config file ### Fixed - Allow ignoring category during challenge sync --- CHANGELOG.md | 13 +++++++++++++ ctfcli/__init__.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eebeba..3889164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +# 0.0.12 / 2023-06-25 + +### Added + +- Add cloud deploy for hosted CTFd instances +- Add the `protocol` field in the challenge.yml spec +- Further define what other deployment methods should provide & return +- Add the ability to add HTTP cookies to ctfcli requests via the config file + +### Fixed + +- Allow ignoring category during challenge sync + # 0.0.11 / 2022-11-09 ### Added diff --git a/ctfcli/__init__.py b/ctfcli/__init__.py index 50db71e..7417c1a 100644 --- a/ctfcli/__init__.py +++ b/ctfcli/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.0.11" +__version__ = "0.0.12" __name__ = "ctfcli" From a9d6f114b57baf1a8ecdd4b809e97110f21616c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Skaza?= Date: Thu, 20 Jul 2023 16:59:20 +0200 Subject: [PATCH 08/58] Plugin improvements (#122) 1. Add env variable `CTFCLI_PLUGIN_DIR` to override default plugin dir - useful for development of plugins. 2. Change plugin load print to a debug log so it's not displayed with every invocation of ctfcli 3. Move plugin loading logic from __main__ file to utils/plugins --- .gitignore | 1 + ctfcli/__main__.py | 17 ++++------------ ctfcli/utils/deploy.py | 2 +- ctfcli/utils/plugins.py | 45 +++++++++++++++++++++++++++++++++++------ 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 343d861..4e7cb1a 100644 --- a/.gitignore +++ b/.gitignore @@ -131,5 +131,6 @@ dmypy.json # Miscellaneous .DS_Store .vscode/ +.idea/ .ctf/ diff --git a/ctfcli/__main__.py b/ctfcli/__main__.py index a6e061e..fecc0f5 100644 --- a/ctfcli/__main__.py +++ b/ctfcli/__main__.py @@ -1,8 +1,6 @@ import configparser -import importlib -import os import subprocess -import sys + from pathlib import Path import click @@ -13,7 +11,7 @@ from ctfcli.cli.plugins import Plugins from ctfcli.cli.templates import Templates from ctfcli.cli.pages import Pages -from ctfcli.utils.plugins import get_plugin_dir +from ctfcli.utils.plugins import load_plugins from ctfcli.utils.git import check_if_dir_is_inside_git_repo @@ -93,15 +91,8 @@ def templates(self): def main(): - # Load plugins - plugin_dir = get_plugin_dir() - sys.path.insert(0, plugin_dir) - for plugin in sorted(os.listdir(plugin_dir)): - plugin_path = os.path.join(plugin_dir, plugin, "__init__.py") - print("Loading", plugin_path, "as", plugin) - loaded = importlib.import_module(plugin) - loaded.load(COMMANDS) - sys.path.remove(plugin_dir) + # load plugins + load_plugins(COMMANDS) # Load CLI fire.Fire(CTFCLI) diff --git a/ctfcli/utils/deploy.py b/ctfcli/utils/deploy.py index a6688d9..e5809a3 100644 --- a/ctfcli/utils/deploy.py +++ b/ctfcli/utils/deploy.py @@ -136,7 +136,7 @@ def cloud(challenge, host, protocol): # Could not find the service. Create it using our pushed image. # Deploy the image by creating service service = s.post( - "/api/v1/services", json={"name": slug, "image": location,} + "/api/v1/services", json={"name": slug, "image": location,}, ).json()["data"] # Get connection details diff --git a/ctfcli/utils/plugins.py b/ctfcli/utils/plugins.py index 6f93c21..4098348 100644 --- a/ctfcli/utils/plugins.py +++ b/ctfcli/utils/plugins.py @@ -1,16 +1,49 @@ -import os - import appdirs +import importlib +import logging +import os +import sys +from pathlib import Path from ctfcli import __name__ as pkg_name +def load_plugins(commands: dict): + plugin_dir = get_plugin_dir() + sys.path.insert(0, plugin_dir) + + for plugin in sorted(os.listdir(plugin_dir)): + plugin_path = Path(plugin_dir) / plugin / "__init__.py" + + logging.debug(f"Loading {plugin_path} as {plugin}") + + loaded = importlib.import_module(plugin) + loaded.load(commands) + + sys.path.remove(str(plugin_dir)) + + def get_plugin_dir(): - plugins_path = os.path.join(get_data_dir(), "plugins") - if not os.path.exists(plugins_path): + if os.getenv("CTFCLI_PLUGIN_DIR"): + plugins_path = get_custom_plugin_dir() + else: + plugins_path = get_data_dir() / "plugins" + + if not plugins_path.exists(): os.makedirs(plugins_path) - return os.path.join(plugins_path) + + return str(plugins_path.absolute()) + + +def get_custom_plugin_dir() -> Path: + custom_plugins_path = Path(os.getenv("CTFCLI_PLUGIN_DIR")) + + if custom_plugins_path.is_absolute(): + return custom_plugins_path + + base_dir = Path().parent.parent + return base_dir / custom_plugins_path def get_data_dir(): - return appdirs.user_data_dir(appname=pkg_name) + return Path(appdirs.user_data_dir(appname=pkg_name)) From 755acf8cd64d5953548ff8a43467003f8f957998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Skaza?= Date: Thu, 20 Jul 2023 17:01:23 +0200 Subject: [PATCH 09/58] add directory option to ctfcli add (#123) This adds `--directory` argument to `ctfcli add` - useful for grouping challenges into separate directories like: `web/challenge1`. --- ctfcli/cli/challenges.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index b6fad3d..2a30d58 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -53,7 +53,7 @@ def templates(self): Templates().list() - def add(self, repo, yaml_path=None): + def add(self, repo, directory=None, yaml_path=None): config = load_config() if repo.endswith(".git"): @@ -63,9 +63,11 @@ def add(self, repo, yaml_path=None): # Get new directory that will add the git subtree base_repo_path = Path(os.path.basename(repo).rsplit(".", maxsplit=1)[0]) + if directory: + base_repo_path = directory / base_repo_path + # Join targets challenge_path = challenge_path / base_repo_path - print(challenge_path) # If a custom yaml_path is specified we add it to our challenge_key if yaml_path: From faa84099dbcb92d245601aeb244e24c066a2c5cd Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Thu, 27 Jul 2023 15:59:20 -0400 Subject: [PATCH 10/58] Bump PyYAML version to 6.0.1 (#125) * Bump PyYAML version to 6.0.1 * Closes #124 --- requirements.in | 2 +- requirements.txt | 6 +++--- scripts/pip-compile.sh | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements.in b/requirements.in index 3531835..733c6ce 100644 --- a/requirements.in +++ b/requirements.in @@ -1,7 +1,7 @@ cookiecutter==2.1.1 click==7.0 fire==0.2.1 -pyyaml==5.4 +PyYAML==6.0.1 Pygments==2.7.4 requests==2.28.1 colorama==0.4.6 diff --git a/requirements.txt b/requirements.txt index c61bc62..b472c95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: # # ./scripts/pip-compile.sh # @@ -44,7 +44,7 @@ python-frontmatter==1.0.0 # via -r requirements.in python-slugify==6.1.2 # via cookiecutter -pyyaml==5.4 +pyyaml==6.0.1 # via # -r requirements.in # cookiecutter diff --git a/scripts/pip-compile.sh b/scripts/pip-compile.sh index 31e9342..21f404b 100755 --- a/scripts/pip-compile.sh +++ b/scripts/pip-compile.sh @@ -7,4 +7,4 @@ docker run \ -v $ROOTDIR:/mnt/ctfcli \ -e CUSTOM_COMPILE_COMMAND='./scripts/pip-compile.sh' \ -it python:3.9-slim-buster \ - -c 'cd /mnt/ctfcli && pip install pip-tools==6.6.0 && pip-compile' \ No newline at end of file + -c 'cd /mnt/ctfcli && pip install pip-tools==6.14.0 && pip-compile' \ No newline at end of file From 1c11ea642da07fb95260edf3974566f69d37a97f Mon Sep 17 00:00:00 2001 From: Tom Plant <21111317+pl4nty@users.noreply.github.com> Date: Fri, 28 Jul 2023 06:01:04 +1000 Subject: [PATCH 11/58] Allow provided `connection_info` in `deploy` (#121) * feat: allow provided `connection_info` in `deploy` * Add some logging information to show that we will use hardcoded connection_info * Fixes #120 --------- Co-authored-by: Kevin Chung --- ctfcli/cli/challenges.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index 2a30d58..b672a58 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -339,7 +339,15 @@ def deploy(self, challenge, host=None, protocol=None): challenge=challenge, host=target_host, protocol=protocol, ) - challenge["connection_info"] = connect_info + # Use hardcoded connection_info if specified + connection_info = connect_info + if challenge.get("connection_info"): + click.secho( + "Using connection_info hardcoded in challenge.yml", fg="yellow", + ) + connect_info = challenge.get("connection_info") + + challenge["connection_info"] = connection_info if status: # Search for challenge From b9e47ebd77c552ab8f612560ba107b988a044d2c Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 29 Jul 2023 19:30:16 -0400 Subject: [PATCH 12/58] Fix the PyYAML version in setup.py (#129) --- requirements.in | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.in b/requirements.in index 733c6ce..391a586 100644 --- a/requirements.in +++ b/requirements.in @@ -1,7 +1,7 @@ cookiecutter==2.1.1 click==7.0 fire==0.2.1 -PyYAML==6.0.1 +PyYAML>=6.0.1 Pygments==2.7.4 requests==2.28.1 colorama==0.4.6 diff --git a/setup.py b/setup.py index bba699b..e0bf9fa 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def read(fname): "cookiecutter==2.1.1", "click==7.0", "fire==0.2.1", - "pyyaml==5.4", + "pyyaml>=6.0.1", "Pygments==2.7.4", "requests==2.28.1", "colorama==0.4.6", From 7b4a09af8414eb1f5f6da9a8422fb53b5e9cbc15 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sat, 29 Jul 2023 19:32:15 -0400 Subject: [PATCH 13/58] Mark 0.0.13 (#128) # 0.0.13 / 2023-07-29 ### Added - Add env variable `CTFCLI_PLUGIN_DIR` to override the default plugin dir for development. - Add `--directory` argument to `ctfcli challenge add` - Can also be called as `ctf challenge add git@github.com:repo.git directory` - Useful for grouping challenges into separate directories like: `web/challenge1`. - `connection_info` specified in challenge.yml will be used instead of details generated by a deploy handler ### Fixed - Bump PyYAML version to 6.0.1 --- CHANGELOG.md | 14 ++++++++++++++ ctfcli/__init__.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3889164..c629c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +# 0.0.13 / 2023-07-29 + +### Added + +- Add env variable `CTFCLI_PLUGIN_DIR` to override the default plugin dir for development. +- Add `--directory` argument to `ctfcli challenge add` + - Can also be called as `ctf challenge add git@github.com:repo.git directory` + - Useful for grouping challenges into separate directories like: `web/challenge1`. +- `connection_info` specified in challenge.yml will be used instead of details generated by a deploy handler + +### Fixed + +- Bump PyYAML version to 6.0.1 + # 0.0.12 / 2023-06-25 ### Added diff --git a/ctfcli/__init__.py b/ctfcli/__init__.py index 7417c1a..e8e8029 100644 --- a/ctfcli/__init__.py +++ b/ctfcli/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.0.12" +__version__ = "0.0.13" __name__ = "ctfcli" From c385e70c5553dea013b1b6c99fbe353324e5f3fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Skaza?= Date: Wed, 23 Aug 2023 18:15:25 +0200 Subject: [PATCH 14/58] Refactor CTFCLI to more OOP approach (#130) * Refactor ctfcli structure * Switch to poetry --------- Co-authored-by: Kevin Chung --- .editorconfig | 13 + .github/workflows/lint.yml | 13 +- .github/workflows/release.yml | 14 +- .github/workflows/test.yml | 28 + .gitignore | 1 + .gitlab-ci.yml | 11 - MANIFEST.in | 1 + Makefile | 16 +- ctfcli/__init__.py | 2 +- ctfcli/__main__.py | 145 +- ctfcli/cli/challenges.py | 1103 +++++++++---- ctfcli/cli/config.py | 67 +- ctfcli/cli/pages.py | 126 +- ctfcli/cli/plugins.py | 111 +- ctfcli/cli/templates.py | 106 +- ctfcli/core/__init__.py | 0 ctfcli/core/api.py | 57 + ctfcli/core/challenge.py | 510 ++++++ ctfcli/core/config.py | 112 ++ ctfcli/core/deployment/__init__.py | 20 + ctfcli/core/deployment/base.py | 27 + ctfcli/core/deployment/cloud.py | 198 +++ ctfcli/core/deployment/registry.py | 98 ++ ctfcli/core/deployment/ssh.py | 95 ++ ctfcli/core/exceptions.py | 57 + ctfcli/core/image.py | 64 + ctfcli/core/page.py | 238 +++ ctfcli/core/plugins.py | 23 + .../crypto/default/cookiecutter.json | 2 +- .../default/{{cookiecutter.name}}/src/app.py | 3 +- ctfcli/utils/api.py | 17 - ctfcli/utils/challenge.py | 408 ----- ctfcli/utils/config.py | 88 - ctfcli/utils/deploy.py | 179 --- ctfcli/utils/git.py | 36 +- ctfcli/utils/images.py | 51 - ctfcli/utils/pages.py | 71 - ctfcli/utils/plugins.py | 49 - ctfcli/utils/spec.py | 122 -- ctfcli/utils/templates.py | 16 - ctfcli/utils/tools.py | 10 +- development.txt | 7 - poetry.lock | 877 ++++++++++ pyproject.toml | 50 + requirements.txt | 65 - scripts/pip-compile.sh | 10 - setup.py | 20 +- tests/__init__.py | 0 tests/cli/test_main.py | 71 + tests/core/__init__.py | 0 tests/core/deployment/__init__.py | 0 .../test_base_deployment_handler.py | 57 + .../core/deployment/test_cloud_deployment.py | 1200 ++++++++++++++ .../deployment/test_registry_deployment.py | 282 ++++ tests/core/deployment/test_ssh_deployment.py | 240 +++ tests/core/test_api.py | 173 ++ tests/core/test_challenge.py | 1427 +++++++++++++++++ tests/core/test_config.py | 193 +++ tests/core/test_exceptions.py | 56 + tests/core/test_image.py | 291 ++++ tests/core/test_page.py | 767 +++++++++ tests/core/test_plugins.py | 22 + tests/fixtures/challenges/.ctf/config | 10 + .../fixtures/challenges/pages/html-page.html | 9 + .../challenges/pages/markdown-page.md | 9 + .../challenges/pages/nested/html-page.htm | 9 + .../test-challenge-dockerfile/Dockerfile | 6 + .../test-challenge-dockerfile/challenge.yml | 9 + .../test-challenge-dockerfile/src/index.html | 13 + .../test-challenge-files/challenge.yml | 11 + .../test-challenge-files/files/flag.txt | 1 + .../test-challenge-files/files/test.pdf | Bin .../test-challenge-files/files/test.png | Bin 0 -> 7532 bytes .../test-challenge-full/challenge.yml | 42 + .../test-challenge-full/files/test.pdf | Bin .../test-challenge-full/files/test.png | Bin 0 -> 7532 bytes .../Dockerfile | 4 + .../challenge.yml | 8 + .../challenge-empty.yml | 1 + .../challenge-invalid.yml | 3 + .../test-challenge-minimal/challenge.yml | 7 + tests/utils/__init__.py | 0 tests/utils/test_git.py | 138 ++ tests/utils/test_tools.py | 27 + 84 files changed, 8782 insertions(+), 1641 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/test.yml delete mode 100644 .gitlab-ci.yml create mode 100644 ctfcli/core/__init__.py create mode 100644 ctfcli/core/api.py create mode 100644 ctfcli/core/challenge.py create mode 100644 ctfcli/core/config.py create mode 100644 ctfcli/core/deployment/__init__.py create mode 100644 ctfcli/core/deployment/base.py create mode 100644 ctfcli/core/deployment/cloud.py create mode 100644 ctfcli/core/deployment/registry.py create mode 100644 ctfcli/core/deployment/ssh.py create mode 100644 ctfcli/core/exceptions.py create mode 100644 ctfcli/core/image.py create mode 100644 ctfcli/core/page.py create mode 100644 ctfcli/core/plugins.py delete mode 100644 ctfcli/utils/api.py delete mode 100644 ctfcli/utils/challenge.py delete mode 100644 ctfcli/utils/config.py delete mode 100644 ctfcli/utils/deploy.py delete mode 100644 ctfcli/utils/images.py delete mode 100644 ctfcli/utils/pages.py delete mode 100644 ctfcli/utils/plugins.py delete mode 100644 ctfcli/utils/spec.py delete mode 100644 ctfcli/utils/templates.py delete mode 100644 development.txt create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100755 scripts/pip-compile.sh create mode 100644 tests/__init__.py create mode 100644 tests/cli/test_main.py create mode 100644 tests/core/__init__.py create mode 100644 tests/core/deployment/__init__.py create mode 100644 tests/core/deployment/test_base_deployment_handler.py create mode 100644 tests/core/deployment/test_cloud_deployment.py create mode 100644 tests/core/deployment/test_registry_deployment.py create mode 100644 tests/core/deployment/test_ssh_deployment.py create mode 100644 tests/core/test_api.py create mode 100644 tests/core/test_challenge.py create mode 100644 tests/core/test_config.py create mode 100644 tests/core/test_exceptions.py create mode 100644 tests/core/test_image.py create mode 100644 tests/core/test_page.py create mode 100644 tests/core/test_plugins.py create mode 100644 tests/fixtures/challenges/.ctf/config create mode 100644 tests/fixtures/challenges/pages/html-page.html create mode 100644 tests/fixtures/challenges/pages/markdown-page.md create mode 100644 tests/fixtures/challenges/pages/nested/html-page.htm create mode 100644 tests/fixtures/challenges/test-challenge-dockerfile/Dockerfile create mode 100644 tests/fixtures/challenges/test-challenge-dockerfile/challenge.yml create mode 100644 tests/fixtures/challenges/test-challenge-dockerfile/src/index.html create mode 100644 tests/fixtures/challenges/test-challenge-files/challenge.yml create mode 100644 tests/fixtures/challenges/test-challenge-files/files/flag.txt create mode 100644 tests/fixtures/challenges/test-challenge-files/files/test.pdf create mode 100644 tests/fixtures/challenges/test-challenge-files/files/test.png create mode 100644 tests/fixtures/challenges/test-challenge-full/challenge.yml create mode 100644 tests/fixtures/challenges/test-challenge-full/files/test.pdf create mode 100644 tests/fixtures/challenges/test-challenge-full/files/test.png create mode 100644 tests/fixtures/challenges/test-challenge-invalid-dockerfile/Dockerfile create mode 100644 tests/fixtures/challenges/test-challenge-invalid-dockerfile/challenge.yml create mode 100644 tests/fixtures/challenges/test-challenge-invalid/challenge-empty.yml create mode 100644 tests/fixtures/challenges/test-challenge-invalid/challenge-invalid.yml create mode 100644 tests/fixtures/challenges/test-challenge-minimal/challenge.yml create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_git.py create mode 100644 tests/utils/test_tools.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..24655f3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.py] +charset = utf-8 +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d7a51d3..12abe61 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,25 +5,24 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8'] + python-version: ['3.11'] name: Linting steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} architecture: x64 - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install -r development.txt + pip install poetry + poetry install - name: Lint - run: make lint + run: poetry run make lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf8a86e..164261b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8] + python-version: [3.11] # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -22,20 +22,20 @@ jobs: - uses: actions/checkout@v2 # Set up Python environment - - name: Set up Python 3.8 - uses: actions/setup-python@v2 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.11 # Install dependencies - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r development.txt + pip install poetry + poetry install # Build wheels - name: Build wheels - run: python setup.py sdist bdist_wheel + run: poetry run python setup.py sdist bdist_wheel - uses: actions/upload-artifact@v2 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..de09b95 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +--- +name: Testing + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11'] + + name: Testing + steps: + - uses: actions/checkout@v3 + - name: Setup python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + pip install poetry + poetry install + + - name: Test + run: poetry run make test diff --git a/.gitignore b/.gitignore index 4e7cb1a..e4bd32f 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ dmypy.json .idea/ .ctf/ +!tests/fixtures/challenges/.ctf diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 428e81a..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,11 +0,0 @@ -image: python:3.7 - -before_script: - - python -V - - mkdir -p .pip - - pip install --upgrade pip - - pip install --cache-dir='.pip' -r development.txt - -test: - script: - - make lint diff --git a/MANIFEST.in b/MANIFEST.in index 75819ca..c02efd7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include README.md recursive-include ctfcli/templates * recursive-include ctfcli/utils * recursive-include ctfcli/spec * +recursive-exclude tests * \ No newline at end of file diff --git a/Makefile b/Makefile index 4401321..dc11046 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,15 @@ lint: - ruff check --ignore=E402,E501,E712,I002 --exclude=ctfcli/templates --exclude=build . - black --check --exclude=ctfcli/templates . + black --check . + isort --check . + ruff check . format: - black --exclude=ctfcli/templates . + black . + isort . + ruff --fix . + +test: + pytest --cov=ctfcli tests install: python3 setup.py install @@ -15,6 +21,8 @@ clean: rm -rf build/ rm -rf dist/ rm -rf ctfcli.egg-info/ + rm -rf .ruff_cache + rm -f .coverage publish-test: @echo "Publishing to TestPyPI" @@ -26,4 +34,4 @@ publish-pypi: @echo "Publishing to PyPI" @echo "ARE YOU ABSOLUTELY SURE? [y/N] " && read ans && [ $${ans:-N} == y ] python3 setup.py sdist bdist_wheel - twine upload --repository pypi dist/* \ No newline at end of file + twine upload --repository pypi dist/* diff --git a/ctfcli/__init__.py b/ctfcli/__init__.py index e8e8029..bd87dd8 100644 --- a/ctfcli/__init__.py +++ b/ctfcli/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.0.13" +__version__ = "0.1.0" __name__ = "ctfcli" diff --git a/ctfcli/__main__.py b/ctfcli/__main__.py index fecc0f5..0a9a72b 100644 --- a/ctfcli/__main__.py +++ b/ctfcli/__main__.py @@ -1,68 +1,102 @@ import configparser +import logging +import os import subprocess - +import sys from pathlib import Path +from typing import Optional, Union import click import fire -from ctfcli.cli.challenges import Challenge -from ctfcli.cli.config import Config -from ctfcli.cli.plugins import Plugins -from ctfcli.cli.templates import Templates -from ctfcli.cli.pages import Pages -from ctfcli.utils.plugins import load_plugins +from ctfcli.cli.challenges import ChallengeCommand +from ctfcli.cli.config import ConfigCommand +from ctfcli.cli.pages import PagesCommand +from ctfcli.cli.plugins import PluginsCommand +from ctfcli.cli.templates import TemplatesCommand +from ctfcli.core.exceptions import ProjectNotInitialized +from ctfcli.core.plugins import load_plugins from ctfcli.utils.git import check_if_dir_is_inside_git_repo +# Init logging +logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO").upper()) + +log = logging.getLogger("ctfcli.main") + + +class CTFCLI: + @staticmethod + def init( + directory: Optional[Union[str, os.PathLike]] = None, + no_git: bool = False, + no_commit: bool = False, + ): + log.debug(f"init: (directory={directory}, no_git={no_git}, no_commit={no_commit})") + project_path = Path.cwd() -class CTFCLI(object): - def init(self, directory=None, no_config=False, no_git=False): - # Create our event directory if requested and use it as our base directory + # Create our project directory if requested if directory: - path = Path(directory) - path.mkdir() - click.secho(f"Created empty directory in {path.absolute()}", fg="green") - else: - path = Path(".") + project_path = Path(directory) - # Get variables from user - ctf_url = click.prompt( - "Please enter CTFd instance URL", default="", show_default=False - ) - ctf_token = click.prompt( - "Please enter CTFd Admin Access Token", default="", show_default=False - ) - # Confirm information with user - if ( - click.confirm(f"Do you want to continue with {ctf_url} and {ctf_token}") - is False - ): - click.echo("Aborted!") - return + if not project_path.exists(): + project_path.mkdir(parents=True) + click.secho(f"Created empty directory in {project_path.absolute()}", fg="green") # Avoid colliding with existing .ctf directory - if (path / ".ctf").exists(): + if (project_path / ".ctf").exists(): click.secho(".ctf/ folder already exists. Aborting!", fg="red") return + log.debug(f"project_path: {project_path}") + # Create .ctf directory - (path / ".ctf").mkdir() + (project_path / ".ctf").mkdir() + + # Get variables from user + ctf_url = click.prompt("Please enter CTFd instance URL", default="", show_default=False) + + ctf_token = click.prompt("Please enter CTFd Admin Access Token", default="", show_default=False) + + # Confirm information with user + if not click.confirm(f"Do you want to continue with {ctf_url} and {ctf_token}", default=True): + click.echo("Aborted!") + return # Create initial .ctf/config file config = configparser.ConfigParser() config["config"] = {"url": ctf_url, "access_token": ctf_token} config["challenges"] = {} - with (path / ".ctf" / "config").open(mode="a+") as f: - config.write(f) + with (project_path / ".ctf" / "config").open(mode="a+") as config_file: + config.write(config_file) - # Create a git repo in the event folder - if check_if_dir_is_inside_git_repo(dir=path.absolute()) is True: - click.secho("Already in git repo. Skipping git init.", fg="yellow") - elif no_git is True: + # if git init is to be skipped we can return + if no_git: click.secho("Skipping git init.", fg="yellow") - else: - click.secho(f"Creating git repo in {path.absolute()}", fg="green") - subprocess.call(["git", "init", str(path)]) + return + + # also skip git init if git is already initialized + if check_if_dir_is_inside_git_repo(cwd=project_path): + click.secho("Already in a git repo. Skipping git init.", fg="yellow") + + # is git commit is to be skipped we can return + if no_commit: + click.secho("Skipping git commit.", fg="yellow") + return + + subprocess.call(["git", "add", ".ctf/config"], cwd=project_path) + subprocess.call(["git", "commit", "-m", "init ctfcli project"], cwd=project_path) + return + + # Create a git repo in the project folder + click.secho(f"Creating a git repo in {project_path}", fg="green") + subprocess.call(["git", "init", str(project_path)]) + + if no_commit: + click.secho("Skipping git commit.", fg="yellow") + return + + subprocess.call(["git", "add", ".ctf/config"], cwd=project_path) + subprocess.call(["git", "commit", "-m", "init ctfcli project"], cwd=project_path) def config(self): return COMMANDS.get("config") @@ -81,21 +115,38 @@ def templates(self): COMMANDS = { - "challenge": Challenge(), - "config": Config(), - "pages": Pages(), - "plugins": Plugins(), - "templates": Templates(), + "challenge": ChallengeCommand(), + "config": ConfigCommand(), + "pages": PagesCommand(), + "plugins": PluginsCommand(), + "templates": TemplatesCommand(), "cli": CTFCLI(), } def main(): - # load plugins + # Load plugins load_plugins(COMMANDS) # Load CLI - fire.Fire(CTFCLI) + try: + # if the command returns an int, then we serialize it as none to prevent fire from printing it + # (this does not change the actual return value, so it's still good to use as an exit code) + # everything else is returned as is, so fire can print help messages + ret = fire.Fire(CTFCLI, serialize=lambda r: None if isinstance(r, int) else r) + + if isinstance(ret, int): + sys.exit(ret) + + except ProjectNotInitialized: + if click.confirm( + "Outside of a ctfcli project, would you like to start a new project in this directory?", + default=False, + ): + CTFCLI.init() + except KeyboardInterrupt: + click.secho("\n[Ctrl-C] Aborting.", fg="red") + sys.exit(2) if __name__ == "__main__": diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index b672a58..b413fe9 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -1,84 +1,179 @@ +import logging import os import subprocess -import sys from pathlib import Path +from typing import Tuple, Union from urllib.parse import urlparse import click -import yaml from cookiecutter.main import cookiecutter - -from ctfcli.utils.challenge import ( - create_challenge, - lint_challenge, - load_challenge, - load_installed_challenge, - load_installed_challenges, - sync_challenge, -) -from ctfcli.utils.config import ( - get_base_path, - get_config_path, - get_project_path, - load_config, -) +from pygments import highlight +from pygments.formatters.terminal import TerminalFormatter +from pygments.lexers.data import YamlLexer + +from ctfcli.core.challenge import Challenge +from ctfcli.core.config import Config +from ctfcli.core.deployment import get_deployment_handler +from ctfcli.core.exceptions import ChallengeException, LintException from ctfcli.utils.git import get_git_repo_head_branch -from ctfcli.utils.spec import CHALLENGE_SPEC_DOCS, blank_challenge_spec -from ctfcli.utils.templates import get_template_dir -from ctfcli.utils.deploy import DEPLOY_HANDLERS + +log = logging.getLogger("ctfcli.cli.challenges") -class Challenge(object): - def new(self, type="blank"): +class ChallengeCommand: + def new(self, type: str = "blank") -> int: + log.debug(f"new: (type={type})") + config = Config() + if type == "blank": - path = Path(get_base_path()) - path = path / "templates" / type / "default" - cookiecutter(str(path)) - else: - # Check if we're referencing an installed template - template_dir = Path(get_template_dir()) - template_path = template_dir / type - - if template_path.is_dir(): # If we found a template directory, use it - cookiecutter(str(template_path)) - else: # If we didn't, use a built in template - path = Path(get_base_path()) - if os.sep not in type: - type += os.sep + "default" - path = path / "templates" / type - cookiecutter(str(path)) - - def templates(self): - from ctfcli.cli.templates import Templates - - Templates().list() - - def add(self, repo, directory=None, yaml_path=None): - config = load_config() + template_path = config.base_path / "templates" / type / "default" + log.debug(f"template_path: {template_path}") + cookiecutter(str(template_path)) + return 0 + + # Check if we're referencing an installed template + template_path = config.templates_path / type + if template_path.is_dir(): # If we found a template directory, use it + log.debug(f"template_path: {template_path}") + cookiecutter(str(template_path)) + return 0 + + # If we didn't, use a built-in template + if os.sep not in type: + # if variant wasn't specified use the default + template_path = config.base_path / "templates" / type / "default" + log.debug(f"template_path: {template_path}") + cookiecutter(str(template_path)) + return 0 + + template_path = config.base_path / "templates" / type + if template_path.is_dir(): + log.debug(f"template_path: {template_path}") + cookiecutter(str(template_path)) + return 0 + + click.secho( + f"Could not locate template '{type}' in either installed or built-in templates", + fg="red", + ) + return 1 + + def edit(self, challenge: str, dockerfile: bool = False) -> int: + log.debug(f"edit: {challenge} (dockerfile={dockerfile})") + config = Config() + + requested_challenge = config["challenges"].get(challenge, None) + if not requested_challenge: + click.secho( + f"Could not find added challenge '{challenge}' " + "Please check that the challenge is added to .ctf/config and that your path matches", + fg="red", + ) + return 1 + + challenge_path = config.project_path / challenge + if not challenge.endswith(".yml"): + challenge_path = challenge_path / "challenge.yml" + + try: + challenge = Challenge(challenge_path) + except ChallengeException as e: + click.secho(str(e), fg="red") + return 1 + + edited_file_path = challenge_path + if dockerfile: + dockerfile_path = config.project_path / challenge_path.parent / challenge.get("image", ".") + if not str(dockerfile_path).endswith("Dockerfile"): + dockerfile_path = dockerfile_path / "Dockerfile" + + if not dockerfile_path.exists(): + click.secho( + f"Could not open Dockerfile for editing, because it could not be found at: {dockerfile_path}", + fg="red", + ) + return 1 + + edited_file_path = dockerfile_path + + editor = os.getenv("EDITOR", "vi") + log.debug(f"call(['{editor}', '{edited_file_path}'])") + subprocess.call([editor, edited_file_path]) + return 0 + + def show(self, challenge: str, color=True) -> int: + log.debug(f"show: {challenge} (color={color})") + return self.view(challenge, color=color) + + def view(self, challenge: str, color=True) -> int: + log.debug(f"view: {challenge} (color={color})") + config = Config() + + requested_challenge = config["challenges"].get(challenge, None) + if not requested_challenge: + click.secho( + f"Could not find added challenge '{challenge}' " + "Please check that the challenge is added to .ctf/config and that your path matches", + fg="red", + ) + return 1 + + challenge_path = config.project_path / challenge + if not challenge.endswith(".yml"): + challenge_path = challenge_path / "challenge.yml" + + with open(challenge_path, "r") as challenge_yml_file: + challenge_yml = challenge_yml_file.read() + + if color: + click.echo(highlight(challenge_yml, YamlLexer(), TerminalFormatter())) + return 0 + + click.echo(challenge_yml) + return 0 + + def templates(self) -> int: + log.debug("templates") + from ctfcli.cli.templates import TemplatesCommand + + return TemplatesCommand.list() + + def add(self, repo: str, directory: str = None, yaml_path: str = None) -> int: + log.debug(f"add: {repo} (directory={directory}, yaml_path={yaml_path})") + config = Config() + # check if we're working with a remote challenge which has to be pulled first if repo.endswith(".git"): # Get relative path from project root to current directory - challenge_path = Path(os.path.relpath(os.getcwd(), get_project_path())) + project_path = config.project_path + project_relative_cwd = Path.cwd().relative_to(project_path) # Get new directory that will add the git subtree - base_repo_path = Path(os.path.basename(repo).rsplit(".", maxsplit=1)[0]) + repository_basename = Path(repo).stem + # Use the custom subdirectory for the challenge, if one was provided + repository_path = repository_basename if directory: - base_repo_path = directory / base_repo_path + custom_directory_path = Path(directory) + repository_path = custom_directory_path / repository_basename # Join targets - challenge_path = challenge_path / base_repo_path + challenge_path = project_relative_cwd / repository_path # If a custom yaml_path is specified we add it to our challenge_key + challenge_key = challenge_path if yaml_path: - challenge_key = str(challenge_path / yaml_path) - else: - challenge_key = str(challenge_path) - - config["challenges"][challenge_key] = repo + challenge_key = challenge_key / yaml_path + # Add new challenge to the config + config["challenges"][str(challenge_key)] = repo head_branch = get_git_repo_head_branch(repo) - subprocess.call( + + log.debug( + f"call(['git', 'subtree', 'add', '--prefix', '{challenge_path}', " + f"'{repo}', '{head_branch}', '--squash'], cwd='{project_path}')" + ) + git_subtree_add = subprocess.call( [ "git", "subtree", @@ -89,365 +184,689 @@ def add(self, repo, directory=None, yaml_path=None): head_branch, "--squash", ], - cwd=get_project_path(), + cwd=project_path, ) - with open(get_config_path(), "w+") as f: + + if git_subtree_add != 0: + click.secho( + "Could not add the challenge subtree. " "Please check git error messages above.", + fg="red", + ) + return 1 + + with open(config.config_path, "w+") as config_file: + config.write(config_file) + + log.debug(f"call(['git', 'add', '.ctf/config'], cwd='{project_path}')") + git_add = subprocess.call(["git", "add", ".ctf/config"], cwd=project_path) + + log.debug(f"call(['git', 'commit', '-m', 'Added {challenge_path}'], cwd='{project_path}')") + git_commit = subprocess.call(["git", "commit", "-m", f"Added {challenge_path}"], cwd=project_path) + + if any(r != 0 for r in [git_add, git_commit]): + click.secho( + "Could not commit the challenge subtree. " "Please check git error messages above.", + fg="red", + ) + return 1 + + return 0 + + # otherwise - we're working with a folder path + if Path(repo).exists(): + config["challenges"][repo] = repo + with open(config.config_path, "w+") as f: config.write(f) - subprocess.call( - ["git", "add", ".ctf/config"], cwd=get_project_path(), + return 0 + + click.secho(f"Could not process the challenge path: '{repo}'", fg="red") + return 1 + + def push(self, challenge: str = None) -> int: + log.debug(f"push: (challenge={challenge})") + config = Config() + + challenge_path = Path.cwd() + if challenge: + challenge_path = config.project_path / Path(challenge) + + # Get a relative path from project root to the challenge + # As this is what git subtree push requires + challenge_path = challenge_path.relative_to(config.project_path) + challenge_repo = config.challenges.get(str(challenge_path), None) + + # if we don't find the challenge by the directory, + # check if it's saved with direct path to challenge.yml + if not challenge_repo: + challenge_repo = config.challenges.get(str(challenge_path / "challenge.yml"), None) + + if not challenge_repo: + click.secho( + f"Could not find added challenge '{challenge_path}' " + "Please check that the challenge is added to .ctf/config and that your path matches", + fg="red", ) - subprocess.call( - ["git", "commit", "-m", f"Added {str(challenge_path)}"], - cwd=get_project_path(), + return 1 + + if not challenge_repo.endswith(".git"): + click.secho( + f"Cannot push challenge '{challenge_path}', as it's not a git-based challenge", + fg="yellow", ) + return 1 - elif Path(repo).exists(): - config["challenges"][repo] = repo - with open(get_config_path(), "w+") as f: - config.write(f) - else: + head_branch = get_git_repo_head_branch(challenge_repo) + + log.debug(f"call(['git', 'add', '.'], cwd='{config.project_path / challenge_path}')") + git_add = subprocess.call(["git", "add", "."], cwd=config.project_path / challenge_path) + + log.debug( + f"call(['git', 'commit', '-m', 'Pushing changes to {challenge_path}'], " + f"cwd='{config.project_path / challenge_path}')" + ) + git_commit = subprocess.call( + ["git", "commit", "-m", f"Pushing changes to {challenge_path}"], + cwd=config.project_path / challenge_path, + ) + + if any(r != 0 for r in [git_add, git_commit]): click.secho( - "Couldn't process that challenge path. Please check it for errors.", + "Could not commit the challenge changes. " "Please check git error messages above.", fg="red", ) + return 1 - def restore(self, challenge=None): - config = load_config() - challenges = dict(config["challenges"]) - for folder, url in challenges.items(): - if url.endswith(".git"): - if challenge is not None and folder != challenge: - continue - click.echo(f"Adding git repo {url} to {folder} as subtree") - head_branch = get_git_repo_head_branch(url) - subprocess.call( - [ - "git", - "subtree", - "add", - "--prefix", - folder, - url, - head_branch, - "--squash", - ], - cwd=get_project_path(), - ) - else: - click.echo(f"Skipping {url} - {folder}") + log.debug( + f"call(['git', 'subtree', 'push', '--prefix', '{challenge_path}', '{challenge_repo}', '{head_branch}'], " + f"cwd='{config.project_path / challenge_path}')" + ) + git_subtree_push = subprocess.call( + [ + "git", + "subtree", + "push", + "--prefix", + challenge_path, + challenge_repo, + head_branch, + ], + cwd=config.project_path, + ) - def install(self, challenge=None, force=False, ignore=()): - if challenge is None: - # Get all challenges if not specifying a challenge - config = load_config() - challenges = dict(config["challenges"]).keys() - else: - challenges = [challenge] + if git_subtree_push != 0: + click.secho( + "Could not push the challenge subtree. " "Please check git error messages above.", + fg="red", + ) + return 1 - if isinstance(ignore, str): - ignore = (ignore,) + return 0 - for challenge in challenges: - path = Path(challenge) + def pull(self, challenge: str = None) -> int: + log.debug(f"pull: (challenge={challenge})") + config = Config() - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + challenge_path = Path.cwd() + if challenge: + challenge_path = config.project_path / Path(challenge) - click.secho(f"Found {path}") - challenge = load_challenge(path) - click.secho(f'Loaded {challenge["name"]}', fg="yellow") + # Get a relative path from project root to the challenge + # As this is what git subtree push requires + challenge_path = challenge_path.relative_to(config.project_path) + challenge_repo = config.challenges.get(str(challenge_path), None) - installed_challenges = load_installed_challenges() - for c in installed_challenges: - if c["name"] == challenge["name"]: - click.secho( - f'Already found existing challenge with same name ({challenge["name"]}). Perhaps you meant sync instead of install?', - fg="red", - ) - if force is True: - click.secho( - "Ignoring existing challenge because of --force", - fg="yellow", - ) - else: - break - else: # If we don't break because of duplicated challenge names - click.secho(f'Installing {challenge["name"]}', fg="yellow") - create_challenge(challenge=challenge, ignore=ignore) - click.secho("Success!", fg="green") + # if we don't find the challenge by the directory, + # check if it's saved with direct path to challenge.yml + if not challenge_repo: + challenge_repo = config.challenges.get(str(challenge_path / "challenge.yml"), None) - def sync(self, challenge=None, ignore=()): - if challenge is None: - # Get all challenges if not specifying a challenge - config = load_config() - challenges = dict(config["challenges"]).keys() - else: - challenges = [challenge] + if not challenge_repo: + click.secho( + f"Could not find added challenge '{challenge_path}' " + "Please check that the challenge is added to .ctf/config and that your path matches", + fg="red", + ) + return 1 - if isinstance(ignore, str): - ignore = (ignore,) + if not challenge_repo.endswith(".git"): + click.secho( + f"Cannot pull challenge '{challenge_path}', as it's not a git-based challenge", + fg="yellow", + ) + return 1 + + click.secho(f"Pulling latest '{challenge_repo}' to '{challenge_path}'", fg="blue") + head_branch = get_git_repo_head_branch(challenge_repo) + + log.debug( + f"call(['git', 'subtree', 'pull', '--prefix', '{challenge_path}', " + f"'{challenge_repo}', '{head_branch}', '--squash'], cwd='{config.project_path}')" + ) + git_subtree_pull = subprocess.call( + [ + "git", + "subtree", + "pull", + "--prefix", + challenge_path, + challenge_repo, + head_branch, + "--squash", + ], + cwd=config.project_path, + ) + + if git_subtree_pull != 0: + click.secho( + f"Could not pull the subtree for challenge '{challenge_path}'. " + "Please check git error messages above.", + fg="red", + ) + return 1 - for challenge in challenges: - path = Path(challenge) + log.debug(f"call(['git', 'mergetool'], cwd='{config.project_path / challenge_path}')") + git_mergetool = subprocess.call(["git", "mergetool"], cwd=config.project_path / challenge_path) - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + log.debug(f"call(['git', 'clean', '-f'], cwd='{config.project_path / challenge_path}')") + git_clean = subprocess.call(["git", "clean", "-f"], cwd=config.project_path / challenge_path) - click.secho(f"Found {path}") - challenge = load_challenge(path) - click.secho(f'Loaded {challenge["name"]}', fg="yellow") + log.debug(f"call(['git', 'commit', '--no-edit'], cwd='{config.project_path / challenge_path}')") + subprocess.call(["git", "commit", "--no-edit"], cwd=config.project_path / challenge_path) + + # git commit is allowed to return a non-zero code because it would also mean that there's nothing to commit + if any(r != 0 for r in [git_mergetool, git_clean]): + click.secho( + f"Could not commit the subtree for challenge '{challenge_path}'. " + "Please check git error messages above.", + fg="red", + ) + return 1 + + return 0 + + def restore(self, challenge: str = None) -> int: + log.debug(f"restore: (challenge={challenge})") + config = Config() + + if len(config.challenges.items()) == 0: + click.secho("Could not find any added challenges to restore", fg="yellow") + return 1 + + failed_restores = [] + for challenge_key, challenge_source in config.challenges.items(): + # if challenge is specified, loop through challenges to find it + if challenge is not None and challenge_key != challenge: + continue - installed_challenges = load_installed_challenges() - for c in installed_challenges: - if c["name"] == challenge["name"]: - break - else: + if not challenge_source.endswith(".git"): click.secho( - f'Couldn\'t find existing challenge {challenge["name"]}. Perhaps you meant install instead of sync?', - fg="red", + f"Skipping restore of '{challenge_key}', as it's not a git-based challenge", + fg="yellow", ) - continue # Go to the next challenge in the overall list + continue - click.secho(f'Syncing {challenge["name"]}', fg="yellow") - sync_challenge(challenge=challenge, ignore=ignore) - click.secho("Success!", fg="green") + # Check if we have a target directory, or the challenge is saved as a reference to challenge.yml + # We cannot restore this, as we don't know the root of the challenge to pull the subtree + if challenge_key.endswith(".yml"): + click.secho( + f"Skipping restore of '{challenge_key}', as it was added with a custom yaml_path. " + "Please restore this challenge again manually", + fg="yellow", + ) + failed_restores.append(challenge_key) + continue - def update(self, challenge=None): - config = load_config() - challenges = dict(config["challenges"]) - for folder, url in challenges.items(): - if challenge and challenge != folder: + # Check if target directory exits + if (config.project_path / challenge_key).exists(): + click.secho( + f"Skipping restore of '{challenge_key}', as the target directory exists. " + "Please remove this directory and retry restore.", + fg="yellow", + ) + failed_restores.append(challenge_key) continue - if url.endswith(".git"): - click.echo(f"Pulling latest {url} to {folder}") - head_branch = get_git_repo_head_branch(url) - subprocess.call( - [ - "git", - "subtree", - "pull", - "--prefix", - folder, - url, - head_branch, - "--squash", - ], - cwd=get_project_path(), + + click.secho( + f"Restoring git repo '{challenge_source}' to '{challenge_key}'", + fg="blue", + ) + head_branch = get_git_repo_head_branch(challenge_source) + + log.debug( + f"call(['git', 'subtree', 'add', '--prefix', '{challenge_key}', '{challenge_source}', " + f"'{head_branch}', '--squash'], cwd='{config.project_path}')" + ) + git_subtree_add = subprocess.call( + [ + "git", + "subtree", + "add", + "--prefix", + challenge_key, + challenge_source, + head_branch, + "--squash", + ], + cwd=config.project_path, + ) + + if git_subtree_add != 0: + click.secho( + f"Could not restore the subtree for challenge '{challenge_key}'. " + "Please check git error messages above.", + fg="red", ) - subprocess.call(["git", "mergetool"], cwd=folder) - subprocess.call(["git", "clean", "-f"], cwd=folder) - subprocess.call(["git", "commit", "--no-edit"], cwd=folder) - else: - click.echo(f"Skipping {url} - {folder}") + failed_restores.append(challenge_key) + + if len(failed_restores) == 0: + click.secho("Success! All challenges restored!", fg="green") + return 0 + + click.secho("Restore failed for:", fg="red") + for challenge in failed_restores: + click.echo(f" - {challenge}") + + return 1 - def finalize(self, challenge=None): + def install( + self, challenge: str = None, force: bool = False, hidden: bool = False, ignore: Union[str, Tuple[str]] = () + ) -> int: + log.debug(f"install: (challenge={challenge}, force={force}, hidden={hidden}, ignore={ignore})") + config = Config() + + challenge_keys = [challenge] + + # Get all challenges if not specifying a challenge if challenge is None: - challenge = os.getcwd() + challenge_keys = config.challenges.keys() + + # Check if there are attributes to be ignored, and if there's only one cast it to a tuple + if isinstance(ignore, str): + ignore = (ignore,) - path = Path(challenge) - spec = blank_challenge_spec() - for k in spec: - q = CHALLENGE_SPEC_DOCS.get(k) - fields = q._asdict() + failed_installs = [] + with click.progressbar(challenge_keys, label="Installing challenges") as challenges: + for challenge_key in challenges: + click.echo() # echo a new line as a separator + challenge_path = config.project_path / Path(challenge_key) + + # if the challenge key does not end with .yml - then assume the default challenge.yml location + # otherwise - treat it as a full path + if not challenge_path.name.endswith(".yml"): + challenge_path = challenge_path / "challenge.yml" - ask = False - required = fields.pop("required", False) - if required is False: try: - ask = click.confirm(f"Would you like to add the {k} field?") - if ask is False: + challenge = Challenge(challenge_path) + if hidden: + challenge["state"] = "hidden" + + except ChallengeException as e: + click.secho(str(e), fg="red") + failed_installs.append(challenge_key) + continue + + click.secho( + f"Installing '{challenge['name']}' ({challenge_path.relative_to(config.project_path)}) ...", + fg="blue", + ) + + installed_challenges = challenge.load_installed_challenges() + found_duplicate = False + for c in installed_challenges: + if c["name"] == challenge["name"]: + click.secho( + f"Found already existing challenge with the same name ({challenge['name']}). " + "Perhaps you meant sync instead of install?", + fg="red", + ) + found_duplicate = True + + if found_duplicate: + if not force: + failed_installs.append(challenge_key) continue - except click.Abort: - click.echo("\n") + + click.secho("Syncing existing challenge instead (because of --force)", fg="yellow") + try: + challenge.sync(ignore=ignore) + except ChallengeException as e: + click.secho("Failed to sync challenge", fg="red") + click.secho(str(e), fg="red") + failed_installs.append(challenge_key) + continue - if ask is True: - fields["text"] = "\t" + fields["text"] + # If we don't break because of duplicated challenge names - continue the installation + try: + challenge.create(ignore=ignore) + except ChallengeException as e: + click.secho("Failed to install challenge", fg="red") + click.secho(str(e), fg="red") + failed_installs.append(challenge_key) + + if len(failed_installs) == 0: + click.secho("Success! All challenges installed!", fg="green") + return 0 + + click.secho("Install failed for:", fg="red") + for challenge in failed_installs: + click.echo(f" - {challenge}") + + return 1 + + def sync(self, challenge: str = None, ignore: Union[str, Tuple[str]] = ()) -> int: + log.debug(f"sync: (challenge={challenge}, ignore={ignore})") + config = Config() + challenge_keys = [challenge] + + # Get all challenges if not specifying a challenge + if challenge is None: + challenge_keys = config.challenges.keys() + + # Check if there are attributes to be ignored, and if there's only one cast it to a tuple + if isinstance(ignore, str): + ignore = (ignore,) + + failed_syncs = [] + with click.progressbar(challenge_keys, label="Syncing challenges") as challenges: + for challenge_key in challenges: + click.echo() # echo a new line as a separator + challenge_path = config.project_path / Path(challenge_key) + + # if the challenge key does not end with .yml - then assume the default challenge.yml location + # otherwise - treat it as a full path + if not challenge_path.name.endswith(".yml"): + challenge_path = challenge_path / "challenge.yml" - multiple = fields.pop("multiple", False) - if multiple: - fields["text"] += " (Ctrl-C to continue)" - spec[k] = [] try: - while True: - r = click.prompt(**fields) - spec[k].append(r) - except click.Abort: - click.echo("\n") - else: + challenge = Challenge(challenge_path) + except ChallengeException as e: + click.secho(str(e), fg="red") + failed_syncs.append(challenge_key) + continue + + installed_challenges = challenge.load_installed_challenges() + + if not any(c["name"] == challenge["name"] for c in installed_challenges): + click.secho( + f"Could not find existing challenge {challenge['name']}. " + f"Perhaps you meant install instead of sync?", + fg="red", + ) + failed_syncs.append(challenge_key) + continue + + click.secho( + f"Syncing '{challenge['name']}' ({challenge_path.relative_to(config.project_path)}) ...", + fg="blue", + ) try: - r = click.prompt(**fields) - spec[k] = r - except click.Abort: - click.echo("\n") + challenge.sync(ignore=ignore) + except ChallengeException as e: + click.secho("Failed to sync challenge", fg="red") + click.secho(str(e), fg="red") + failed_syncs.append(challenge_key) + + if len(failed_syncs) == 0: + click.secho("Success! All challenges synced!", fg="green") + return 0 + + click.secho("Sync failed for:", fg="red") + for challenge in failed_syncs: + click.echo(f" - {challenge}") - with open(path / "challenge.yml", "w+") as f: - yaml.dump(spec, stream=f, default_flow_style=False, sort_keys=False) + return 1 - print("challenge.yml written to", path / "challenge.yml") + def deploy( + self, + challenge: str = None, + host: str = None, + skip_login: bool = False, + ) -> int: + log.debug(f"deploy: (challenge={challenge}, host={host}, skip_login={skip_login})") + + config = Config() + challenge_keys = [challenge] - def lint(self, challenge=None): if challenge is None: - challenge = os.getcwd() + challenge_keys = config.challenges.keys() - path = Path(challenge) + failed_deployments, failed_syncs = [], [] - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + # get challenges which can be deployed (have an image) + deployable_challenges = [] + for challenge_key in challenge_keys: + challenge_path = config.project_path / Path(challenge_key) - lint_challenge(path) + if not challenge_path.name.endswith(".yml"): + challenge_path = challenge_path / "challenge.yml" - def deploy(self, challenge, host=None, protocol=None): - if challenge is None: - challenge = os.getcwd() + try: + challenge = Challenge(challenge_path) + if challenge.get("image"): + deployable_challenges.append(challenge) - path = Path(challenge) + except ChallengeException as e: + click.secho(str(e), fg="red") + failed_deployments.append(challenge_key) + continue - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + with click.progressbar(deployable_challenges, label="Deploying challenges") as challenges: + for challenge in challenges: + click.echo() # echo a new line as a separator - challenge = load_challenge(path) - image = challenge.get("image") - if image is None: - click.secho( - "This challenge can't be deployed because it doesn't have an associated image", - fg="red", - ) - return + challenge_name = challenge.get("name") + challenge_key = challenge.challenge_file_path.parent + target_host = host or challenge.get("host") - target_host = host or challenge.get("host") - if bool(target_host) is False: - # If we do not have a host we should set to cloud - click.secho( - "No host specified, defaulting to cloud deployment", fg="yellow", - ) - scheme = "cloud" - else: - url = urlparse(target_host) - if bool(url.netloc) is False: + # Default to cloud deployment if host is not specified + scheme = "cloud" + if bool(target_host): + url = urlparse(target_host) + if not bool(url.netloc): + click.secho( + f"Host for challenge service '{challenge_name}' has no URI scheme - {target_host}. " + "Provide a URI scheme like ssh:// or registry://", + fg="red", + ) + continue + + scheme = url.scheme + + deployment_handler = get_deployment_handler(scheme)( + challenge, host=host, protocol=challenge.get("protocol") + ) click.secho( - "Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://", - fg="red", + f"Deploying challenge service '{challenge_name}' " + f"({challenge.challenge_file_path.relative_to(config.project_path)}) " + f"with {deployment_handler.__class__.__name__} ...", + fg="blue", ) - return - scheme = url.scheme + deployment_result = deployment_handler.deploy(skip_login=skip_login) - protocol = protocol or challenge.get("protocol") + # Use hardcoded connection_info if specified + if challenge.get("connection_info"): + click.secho("Using connection_info hardcoded in challenge.yml", fg="yellow") - status, domain, port, connect_info = DEPLOY_HANDLERS[scheme]( - challenge=challenge, host=target_host, protocol=protocol, - ) + # Otherwise, use connection_info from the deployment result if provided + elif deployment_result.connection_info: + challenge["connection_info"] = deployment_result.connection_info - # Use hardcoded connection_info if specified - connection_info = connect_info - if challenge.get("connection_info"): - click.secho( - "Using connection_info hardcoded in challenge.yml", fg="yellow", - ) - connect_info = challenge.get("connection_info") - - challenge["connection_info"] = connection_info - - if status: - # Search for challenge - installed_challenges = load_installed_challenges() - for c in installed_challenges: - # Sync challenge if it already exists - if c["name"] == challenge["name"]: - sync_challenge( - challenge, - ignore=[ - "flags", - "topics", - "tags", - "files", - "hints", - "requirements", - ], + # Finally if no connection_info was provided in the challenge and the + # deployment didn't result in one either, just ensure it's not present + else: + challenge["connection_info"] = None + + if not deployment_result.success: + click.secho("An error occurred during service deployment!", fg="red") + failed_deployments.append(challenge_key) + continue + + installed_challenges = challenge.load_installed_challenges() + existing_challenge = next( + (c for c in installed_challenges if c["name"] == challenge["name"]), + None, + ) + + if challenge["connection_info"]: + click.secho( + f"Challenge service deployed at: {challenge['connection_info']}", + fg="green", + ) + else: + click.secho( + "Could not resolve a connection_info for the deployed service.\nIf your DeploymentHandler " + "does not return a connection_info, make sure to provide one in the challenge.yml file.", + fg="yellow", ) - break - else: - # Install challenge - create_challenge(challenge=challenge) + try: + if existing_challenge: + click.secho(f"Updating challenge '{challenge_name}'", fg="blue") + challenge.sync( + ignore=[ + "flags", + "topics", + "tags", + "files", + "hints", + "requirements", + ] + ) + else: + click.secho(f"Creating challenge '{challenge_name}'", fg="blue") + challenge.create() + + except ChallengeException as e: + click.secho( + "Challenge service has been deployed, however the challenge could not be " + f"{'synced' if existing_challenge else 'created'}", + fg="red", + ) + click.secho(str(e), fg="red") + failed_syncs.append(challenge_key) + + click.secho("Success!\n", fg="green") + + if len(failed_deployments) == 0 and len(failed_syncs) == 0: click.secho( - f"Challenge deployed at {challenge['connection_info']}", fg="green", - ) - else: - click.secho( - "An error occured during deployment", fg="red", + "Success! All challenges deployed and installed or synced.", + fg="green", ) + return 0 - def push(self, challenge=None): - config = load_config() - challenges = dict(config["challenges"]) - if challenge is None: - # Get relative path from project root to current directory - challenge_path = Path(os.path.relpath(os.getcwd(), get_project_path())) - challenge = str(challenge_path) + if len(failed_deployments) > 0: + click.secho("Deployment failed for:", fg="red") + for challenge in failed_deployments: + click.echo(f" - {challenge}") - try: - url = challenges[challenge] - head_branch = get_git_repo_head_branch(url) - subprocess.call( - ["git", "subtree", "push", "--prefix", challenge, url, head_branch], - cwd=get_project_path(), - ) - except KeyError: - click.echo( - "Couldn't process that challenge path. Please check that the challenge is added to .ctf/config and that your path matches." - ) + if len(failed_syncs) > 0: + click.secho("Install / Sync failed for:", fg="red") + for challenge in failed_deployments: + click.echo(f" - {challenge}") + + return 1 - def healthcheck(self, challenge): - config = load_config() - _challenges = config["challenges"] + def lint( + self, + challenge: str = None, + skip_hadolint: bool = False, + flag_format: str = "flag{", + ) -> int: + log.debug(f"lint: (challenge={challenge}, skip_hadolint={skip_hadolint}, flag_format='{flag_format}')") + config = Config() + challenge_path = Path.cwd() - # challenge_path = challenges[challenge] - path = Path(challenge) - if path.name.endswith(".yml") is False: - path = path / "challenge.yml" + if challenge: + challenge_path = config.project_path / Path(challenge) + + if not challenge_path.name.endswith(".yml"): + challenge_path = challenge_path / "challenge.yml" - challenge = load_challenge(path) - click.secho(f'Loaded {challenge["name"]}', fg="yellow") try: - healthcheck = challenge["healthcheck"] - except KeyError: - click.secho(f'{challenge["name"]} missing healthcheck parameter', fg="red") - return + challenge = Challenge(challenge_path) + except ChallengeException as e: + click.secho(str(e), fg="red") + return 1 - # Get challenges installed from CTFd and try to find our challenge - installed_challenges = load_installed_challenges() - target = None - for c in installed_challenges: - if c["name"] == challenge["name"]: - target = c - break - else: + click.secho(f"Loaded {challenge['name']}", fg="blue") + + try: + challenge.lint(skip_hadolint=skip_hadolint, flag_format=flag_format) + except LintException as e: + click.secho("Linting found issues!\n", fg="yellow") + e.print_summary() + return 1 + + click.secho("Success! Lint didn't find any issues!", fg="green") + return 0 + + def healthcheck(self, challenge: str = None): + log.debug(f"lint: (challenge={challenge})") + config = Config() + challenge_path = Path.cwd() + + if challenge: + challenge_path = config.project_path / Path(challenge) + + if not challenge_path.name.endswith(".yml"): + challenge_path = challenge_path / "challenge.yml" + + try: + challenge = Challenge(challenge_path) + except ChallengeException as e: + click.secho(str(e), fg="red") + return 1 + + click.secho(f"Loaded {challenge['name']}", fg="blue") + healthcheck = challenge.get("healthcheck", None) + if not healthcheck: click.secho( - f'Couldn\'t find challenge {c["name"]} on CTFd', fg="red", + f"Challenge '{challenge['name']}' does not define a healthcheck.", + fg="red", ) - return + return 1 - # Get the actual challenge data - installed_challenge = load_installed_challenge(target["id"]) - connection_info = installed_challenge["connection_info"] + # Get challenges installed from CTFd and try to find our challenge + installed_challenges = Challenge.load_installed_challenges() - # Run healthcheck - if connection_info: - rcode = subprocess.call( - [healthcheck, "--connection-info", connection_info], cwd=path.parent - ) - else: - rcode = subprocess.call([healthcheck], cwd=path.parent) + challenge_id = None + for c in installed_challenges: + if challenge["name"] == c["name"]: + challenge_id = c["id"] - if rcode != 0: + if challenge_id is None: click.secho( - "Healcheck failed", fg="red", + f"Could not find existing challenge '{challenge['name']}'. " + f"Challenge needs to be installed and deployed to run a healthcheck.", + fg="red", ) - sys.exit(1) - else: + return 1 + + challenge_data = Challenge.load_installed_challenge(challenge_id) + if not challenge_data: + click.secho(f"Could not load data for challenge '{challenge['name']}'.", fg="red") + return 1 + + connection_info = challenge_data.get("connection_info") + if not connection_info: click.secho( - "Success", fg="green", + f"Challenge '{challenge['name']}' does not provide connection info. " + "Perhaps it needs to be deployed first?", + fg="red", ) - sys.exit(0) + return 1 + + log.debug(f"call(['{healthcheck}', '--connection-info', '{connection_info}'], cwd='{challenge_path.parent}')") + healthcheck_status = subprocess.call( + [healthcheck, "--connection-info", connection_info], + cwd=challenge_path.parent, + ) + + if healthcheck_status != 0: + click.secho("Healthcheck failed!", fg="red") + return 1 + + click.secho("Success! Challenge passed the healthcheck.", fg="green") + return 0 diff --git a/ctfcli/cli/config.py b/ctfcli/cli/config.py index 4c974fc..91300f8 100644 --- a/ctfcli/cli/config.py +++ b/ctfcli/cli/config.py @@ -1,3 +1,4 @@ +import logging import os import subprocess @@ -6,28 +7,50 @@ from pygments.formatters import TerminalFormatter from pygments.lexers import IniLexer, JsonLexer -from ctfcli.utils.config import get_config_path, preview_config +from ctfcli.core.config import Config +log = logging.getLogger("ctfcli.cli.config") -class Config(object): - def edit(self): + +class ConfigCommand: + def edit(self) -> int: + log.debug("edit") editor = os.getenv("EDITOR", "vi") - command = editor, get_config_path() - subprocess.call(command) - - def path(self): - click.echo(get_config_path()) - - def view(self, color=True, json=False): - config = get_config_path() - with open(config) as f: - if json is True: - config = preview_config(as_string=True) - if color: - config = highlight(config, JsonLexer(), TerminalFormatter()) - else: - config = f.read() - if color: - config = highlight(config, IniLexer(), TerminalFormatter()) - - print(config) + + log.debug(f"call(['{editor}', '{Config.get_config_path()}'])") + subprocess.call([editor, Config.get_config_path()]) + return 0 + + def path(self) -> int: + log.debug("path") + click.echo(Config.get_config_path()) + return 0 + + def show(self, color=True, json=False) -> int: + # alias for the view command + log.debug(f"show (color={color}, json={json})") + return self.view(color=color, json=json) + + def view(self, color=True, json=False) -> int: + log.debug(f"view (color={color}, json={json})") + config = Config() + + if json: + config_json = config.as_json(pretty=True) + + if color: + click.echo(highlight(config_json, JsonLexer(), TerminalFormatter())) + return 0 + + click.echo(config_json) + return 0 + + with open(config.get_config_path(), "r") as config_file: + config_ini = config_file.read() + + if color: + click.echo(highlight(config_ini, IniLexer(), TerminalFormatter())) + return 0 + + click.echo(config_ini) + return 0 diff --git a/ctfcli/cli/pages.py b/ctfcli/cli/pages.py index f4a0c7d..29f198e 100644 --- a/ctfcli/cli/pages.py +++ b/ctfcli/cli/pages.py @@ -1,45 +1,95 @@ -import sys -from pathlib import Path -import frontmatter +import logging +from typing import Optional import click -from ctfcli.utils.config import load_config -from ctfcli.utils.pages import ( - get_current_pages, - get_existing_page, - sync_page, - install_page, -) +from ctfcli.core.exceptions import PageException +from ctfcli.core.page import Page +log = logging.getLogger("ctfcli.cli.pages") -class Pages(object): - def install(self): + +class PagesCommand: + # _page_operation is a wrapper to calling Page methods, to avoid code duplication, it will: + # - log the operation + # - perform it on the page object, with given arguments + # - handle the possible exception and echo it out with click + # - return right exit code + def _page_operation(self, page: Page, operation: str, *args, **kwargs) -> int: + if operation not in ["push", "pull", "sync"]: + raise ValueError + + page_operation = getattr(page, operation) try: - _config = load_config() - except Exception as e: - print(e) - click.secho("No ctfcli configuration found", fg="red") - sys.exit(1) - - pages = Path("./pages") - if pages.is_dir() is False: - click.secho( - '"pages" folder not found. All pages must exist in the "pages" folder.', - fg="red", + log.debug(f"{operation}: {page}") + page_operation(*args, **kwargs) + click.secho(f"Successfully {operation}ed page '{page.page_file_path}'", fg="green") + return 0 + except PageException as e: + click.secho(str(e), fg="red") + return 1 + + def push(self, page: Optional[str] = None) -> int: + pages = Page.get_local_pages() + + if page: + page_object = next( + (page_obj for page_obj in pages if page == page_obj.page_file_path), + None, ) - sys.exit(1) - else: - current_pages = get_current_pages() - - pagefiles = list(pages.glob("**/*.md")) + list(pages.glob("**/*.html")) - for path_obj in pagefiles: - page = frontmatter.load(path_obj) - existing_page = get_existing_page( - route=page["route"], pageset=current_pages - ) - - if existing_page: - sync_page(page, path_obj, existing_page["id"]) - else: - install_page(page, path_obj) + if page_object: + return self._page_operation(page_object, "push") + + click.secho(f"Could not find page '{page}'", fg="red") + return 1 + + return_code = 0 + for page_object in pages: + status = self._page_operation(page_object, "push") + + if status == 1: + return_code = 1 + + return return_code + + def sync(self, page: Optional[str] = None) -> int: + pages = Page.get_local_pages() + + if page: + page_object = next( + (page_obj for page_obj in pages if page == page_obj.page_file_path), + None, + ) + if page_object: + return self._page_operation(page_object, "sync") + + click.secho(f"Could not find page '{page}'", fg="red") + return 1 + + return_code = 0 + for page_object in pages: + status = self._page_operation(page_object, "sync") + + if status == 1: + return_code = 1 + + return return_code + + def pull(self, route: Optional[str] = None, force=False) -> int: + if route: + page_id = Page.get_remote_page_id(route) + if not page_id: + click.secho(f"Could not find page with route '{route}'", fg="red") + return 1 + + page_object = Page(page_id=page_id) + return self._page_operation(page_object, "pull", overwrite=force) + + return_code = 0 + pages = Page.get_remote_pages() + for page_object in pages: + status = self._page_operation(page_object, "pull", overwrite=force) + if status == 1: + return_code = 1 + + return return_code diff --git a/ctfcli/cli/plugins.py b/ctfcli/cli/plugins.py index 494506d..1d9b2ae 100644 --- a/ctfcli/cli/plugins.py +++ b/ctfcli/cli/plugins.py @@ -1,40 +1,99 @@ -import os +import logging import shutil import subprocess +from pathlib import Path import click -from ctfcli.utils.plugins import get_plugin_dir +from ctfcli.core.config import Config +log = logging.getLogger("ctfcli.cli.plugins") -class Plugins(object): - def install(self, url): - local_dir = os.path.join( - get_plugin_dir(), os.path.basename(url).rsplit(".", maxsplit=1)[0] - ) - subprocess.call(["git", "clone", url, local_dir]) - pip = shutil.which("pip") - pip3 = shutil.which("pip3") +class PluginsCommand: + @staticmethod + def install(plugin_url: str) -> int: + log.debug(f"install: {plugin_url}") - if pip is None and pip3 is None: - click.secho("Neither pip nor pip3 was found, is it in the PATH?", fg="red") - return + plugins_path = Config.get_plugins_path() + if not plugin_url.endswith(".git"): + click.secho( + "Can only install plugins from git repositories - " f"{plugin_url} does not end with .git", + fg="red", + ) + return 1 - if pip is None: - pip = pip3 + installed_plugin_path = plugins_path / Path(plugin_url).stem + log.debug(f"call(['git', 'clone', '{plugin_url}', '{installed_plugin_path}'])") + git_clone = subprocess.call(["git", "clone", plugin_url, installed_plugin_path]) - subprocess.call( - [pip, "install", "-r", os.path.join(local_dir, "requirements.txt")] - ) + if git_clone != 0: + click.secho( + "Failed to clone the plugin repository. Please check git output above.", + fg="red", + ) + return 1 - def uninstall(self, plugin_name): - plugin_dir = os.path.join(get_plugin_dir(), plugin_name) - shutil.rmtree(plugin_dir) + requirements_path = installed_plugin_path / "requirements.txt" + if requirements_path.exists(): + pip = shutil.which("pip") + pip3 = shutil.which("pip3") - def list(self): - for plugin in sorted(os.listdir(get_plugin_dir())): - print(plugin) + if pip is None and pip3 is None: + click.secho("Neither pip nor pip3 was found, is it in the PATH?", fg="red") + return 1 - def dir(self): - print(get_plugin_dir()) + if pip is None: + pip = pip3 + + log.debug(f"call(['{pip}', 'install', '-r', '{requirements_path}'])") + pip_install = subprocess.call([pip, "install", "-r", requirements_path]) + + if pip_install != 0: + click.secho( + "Failed to install plugin requirements. Please check pip output above.", + fg="red", + ) + return 1 + + return 0 + + @staticmethod + def uninstall(plugin_name: str) -> int: + log.debug(f"uninstall: {plugin_name}") + + plugins_path = Config.get_plugins_path() + plugin_path = plugins_path / plugin_name + + if not plugin_path.exists(): + click.secho(f"Could not find plugin {plugin_name} in {plugins_path}", fg="red") + return 1 + + shutil.rmtree(plugin_path) + return 0 + + @staticmethod + def list() -> int: + log.debug("list") + + installed_plugins = sorted(Config.get_plugins_path().iterdir()) + if len(installed_plugins) == 0: + click.secho("Found no installed plugins", fg="blue") + return 0 + + click.secho("List of installed plugins:", fg="blue") + for plugin in installed_plugins: + click.echo(f" - {plugin}") + + return 0 + + @staticmethod + def dir() -> int: + log.debug("dir") + return PluginsCommand.path() + + @staticmethod + def path() -> int: + log.debug("path") + click.echo(Config.get_plugins_path()) + return 0 diff --git a/ctfcli/cli/templates.py b/ctfcli/cli/templates.py index b248c9f..84f297a 100644 --- a/ctfcli/cli/templates.py +++ b/ctfcli/cli/templates.py @@ -1,36 +1,92 @@ -import os +import logging import shutil import subprocess from glob import glob from pathlib import Path -from ctfcli.utils.config import get_base_path -from ctfcli.utils.templates import get_template_dir +import click +from ctfcli.core.config import Config -class Templates(object): - def install(self, url): - local_dir = os.path.join( - get_template_dir(), os.path.basename(url).rsplit(".", maxsplit=1)[0] - ) - subprocess.call(["git", "clone", url, local_dir]) +log = logging.getLogger("ctfcli.cli.templates") - def uninstall(self, template_name): - template_dir = os.path.join(get_template_dir(), template_name) - shutil.rmtree(template_dir) - def list(self): - # Print included templates - path = Path(get_base_path()) / "templates" - for dir in path.iterdir(): - print(dir.relative_to(path)) +class TemplatesCommand: + @staticmethod + def install(template_url: str) -> int: + log.debug(f"install: {template_url}") - # Print installed templates - template_dir = get_template_dir() + "/" - for template in glob(f"{template_dir}/**/*/cookiecutter.json", recursive=True): - # Remove prefix of template_dir and remove suffix of /cookiecutter.json - template = template[len(template_dir) : -len("/cookiecutter.json")] - print(template) + templates_path = Config.get_templates_path() + if not template_url.endswith(".git"): + click.secho( + "Can only install templates from git repositories - " f"{template_url} does not end with .git", + fg="red", + ) + return 1 - def dir(self): - print(get_template_dir()) + installed_template_path = templates_path / Path(template_url).stem + git_clone = subprocess.call(["git", "clone", template_url, installed_template_path]) + + if git_clone != 0: + click.secho( + "Failed to clone the template repository. Please check git output above.", + fg="red", + ) + return 1 + + return 0 + + @staticmethod + def uninstall(template_name: str) -> int: + log.debug(f"uninstall: {template_name}") + + templates_path = Config.get_templates_path() + template_path = templates_path / template_name + + if not template_path.exists(): + click.secho(f"Could not find template {template_name} in {templates_path}", fg="red") + return 1 + + shutil.rmtree(template_path) + return 0 + + @staticmethod + def list() -> int: + log.debug("list") + + base_path = Config.get_base_path() + templates_path = Config.get_templates_path() + + # Echo built-in templates + built_in_templates = base_path / "templates" + + click.secho("List of built-in templates:", fg="blue") + for template in built_in_templates.iterdir(): + click.echo(f" - {template.relative_to(built_in_templates)}") + + # echo a new line separator + click.echo() + + installed_templates = glob(f"{templates_path}/**/*/cookiecutter.json", recursive=True) + if len(installed_templates) == 0: + click.secho("Found no user-installed templates", fg="blue") + return 0 + + click.secho("List of user-installed templates:", fg="blue") + for template in installed_templates: + # Remove prefix of templates_path (+1 for last slash) and remove suffix of /cookiecutter.json + template_path = str(template)[len(str(templates_path)) + 1 : -len("/cookiecutter.json")] + click.echo(f" - {template_path}") + + return 0 + + @staticmethod + def dir() -> int: + log.debug("dir") + return TemplatesCommand.path() + + @staticmethod + def path() -> int: + log.debug("path") + click.echo(Config.get_templates_path()) + return 0 diff --git a/ctfcli/core/__init__.py b/ctfcli/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ctfcli/core/api.py b/ctfcli/core/api.py new file mode 100644 index 0000000..5487642 --- /dev/null +++ b/ctfcli/core/api.py @@ -0,0 +1,57 @@ +from urllib.parse import urljoin + +from requests import Session + +from ctfcli.core.config import Config + + +class API(Session): + def __init__(self): + config = Config() + + # Load required configuration values + self.url = config["config"]["url"] + self.access_token = config["config"]["access_token"] + + # Handle SSL verification disabling + try: + # Get an ssl_verify config. Default to True if it doesn't exist + ssl_verify = config["config"].getboolean("ssl_verify", True) + except ValueError: + # If we didn't a proper boolean value we should load it as a string + # https://requests.kennethreitz.org/en/master/user/advanced/#ssl-cert-verification + ssl_verify = config["config"].get("ssl_verify") + + super(API, self).__init__() + + # Strip out ending slashes and append a singular one, so we generate + # clean base URLs for both main deployments and subdir deployments + self.prefix_url = self.url.rstrip("/") + "/" + + # Handle SSL verification + self.verify = ssl_verify + + # Handle Authorization + self.headers.update({"Authorization": f"Token {self.access_token}"}) + + # Handle cookies section in config + if "cookies" in config: + self.cookies.update(dict(config["cookies"])) + + def request(self, method, url, *args, **kwargs): + # Strip out the preceding / so that urljoin creates the right url + # considering the appended / on the prefix_url + url = urljoin(self.prefix_url, url.lstrip("/")) + + # if data= is present, do not modify the content-type + if kwargs.get("data", None) is not None: + return super(API, self).request(method, url, *args, **kwargs) + + # otherwise set the content-type to application/json for all API requests + # modify the headers here instead of using self.headers because we don't want to + # override the multipart/form-data case above + if kwargs.get("headers", None) is None: + kwargs["headers"] = {} + + kwargs["headers"]["Content-Type"] = "application/json" + return super(API, self).request(method, url, *args, **kwargs) diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py new file mode 100644 index 0000000..71bbfbb --- /dev/null +++ b/ctfcli/core/challenge.py @@ -0,0 +1,510 @@ +import subprocess +from os import PathLike +from pathlib import Path +from typing import Dict, List, Optional, Union + +import click +import yaml +from slugify import slugify + +from ctfcli.core.api import API +from ctfcli.core.exceptions import ( + InvalidChallengeDefinition, + InvalidChallengeFile, + LintException, + RemoteChallengeNotFound, +) +from ctfcli.core.image import Image +from ctfcli.utils.tools import strings + + +class Challenge(dict): + @staticmethod + def load_installed_challenge(challenge_id) -> Optional[Dict]: + api = API() + r = api.get(f"/api/v1/challenges/{challenge_id}") + + if not r.ok: + return + + installed_challenge = r.json().get("data", None) + if not installed_challenge: + return + + return installed_challenge + + @staticmethod + def load_installed_challenges() -> List: + api = API() + r = api.get("/api/v1/challenges?view=admin") + + if not r.ok: + return [] + + installed_challenges = r.json().get("data", None) + if not installed_challenges: + return [] + + return installed_challenges + + # __init__ expects an absolute path to challenge_yml, or a relative one from the cwd + # it does not join that path with the project_path + def __init__(self, challenge_yml: Union[str, PathLike], overrides=None): + if overrides is None: + overrides = {} + + self.challenge_file_path = Path(challenge_yml) + + if not self.challenge_file_path.is_file(): + raise InvalidChallengeFile(f"Challenge file at {self.challenge_file_path} could not be found") + + self.challenge_directory = self.challenge_file_path.parent + + with open(self.challenge_file_path) as challenge_file: + try: + challenge_definition = yaml.safe_load(challenge_file.read()) + except yaml.YAMLError as e: + raise InvalidChallengeFile(f"Challenge file at {self.challenge_file_path} could not be loaded:\n{e}") + + if type(challenge_definition) != dict: + raise InvalidChallengeFile( + f"Challenge file at {self.challenge_file_path} is either empty or not a dictionary / object" + ) + + challenge_data = {**challenge_definition, **overrides} + super(Challenge, self).__init__(challenge_data) + + # challenge id is unknown before sync or creation + self.challenge_id = None + + # API Session is not generated until it's necessary, but should be reused later + self.api = None + + # Set Image to None if challenge does not provide one + self.image = None + + # get name and build path for the image if challenge provides one + if self.get("image"): + self.image = Image(slugify(self["name"]), self.challenge_directory / self["image"]) + + def _validate_files(self): + # if the challenge defines files, make sure they exist before making any changes to the challenge + for challenge_file in self["files"]: + if not (self.challenge_directory / challenge_file).exists(): + raise InvalidChallengeFile(f"File {challenge_file} could not be loaded") + + def _get_initial_challenge_payload(self, ignore=()) -> Dict: + # alias self as challenge for accessing internal dict data + challenge = self + challenge_payload = { + "name": self["name"], + "category": self.get("category", ""), + "description": self.get("description", ""), + "type": self.get("type", "standard"), + # Hide the challenge for the duration of the sync / creation + "state": "hidden", + } + + # Some challenge types (e.g. dynamic) override value. + # We can't send it to CTFd because we don't know the current value + if challenge.get("value", None) is not None: + # if value is an int as string, cast it + if type(challenge["value"]) == str and challenge["value"].isdigit(): + challenge_payload["value"] = int(challenge["value"]) + + if type(challenge["value"] == int): + challenge_payload["value"] = challenge["value"] + + if "attempts" not in ignore: + challenge_payload["max_attempts"] = challenge.get("attempts", 0) + + if "connection_info" not in ignore: + challenge_payload["connection_info"] = challenge.get("connection_info", None) + + if "extra" not in ignore: + challenge_payload = {**challenge_payload, **challenge.get("extra", {})} + + return challenge_payload + + # Flag delete/create + def _delete_existing_flags(self): + remote_flags = self.api.get("/api/v1/flags").json()["data"] + for flag in remote_flags: + if flag["challenge_id"] == self.challenge_id: + r = self.api.delete(f"/api/v1/flags/{flag['id']}") + r.raise_for_status() + + def _create_flags(self): + for flag in self["flags"]: + if type(flag) == str: + flag_payload = { + "content": flag, + "type": "static", + "challenge_id": self.challenge_id, + } + else: + flag_payload = {**flag, "challenge_id": self.challenge_id} + + r = self.api.post("/api/v1/flags", json=flag_payload) + r.raise_for_status() + + # Topic delete/create + def _delete_existing_topics(self): + remote_topics = self.api.get(f"/api/v1/challenges/{self.challenge_id}/topics").json()["data"] + for topic in remote_topics: + r = self.api.delete(f"/api/v1/topics?type=challenge&target_id={topic['id']}") + r.raise_for_status() + + def _create_topics(self): + for topic in self["topics"]: + r = self.api.post( + "/api/v1/topics", + json={ + "value": topic, + "type": "challenge", + "challenge_id": self.challenge_id, + }, + ) + r.raise_for_status() + + # Tag delete/create + def _delete_existing_tags(self): + remote_tags = self.api.get("/api/v1/tags").json()["data"] + for tag in remote_tags: + if tag["challenge_id"] == self.challenge_id: + r = self.api.delete(f"/api/v1/tags/{tag['id']}") + r.raise_for_status() + + def _create_tags(self): + for tag in self["tags"]: + r = self.api.post( + "/api/v1/tags", + json={"challenge_id": self.challenge_id, "value": tag}, + ) + r.raise_for_status() + + # File delete/create + def _delete_existing_files(self): + remote_challenge = self.load_installed_challenge(self.challenge_id) + remote_files = self.api.get("/api/v1/files?type=challenge").json()["data"] + + for remote_file in remote_files: + for utilized_file in remote_challenge["files"]: + if remote_file["location"] in utilized_file: + r = self.api.delete(f"/api/v1/files/{remote_file['id']}") + r.raise_for_status() + + def _create_files(self): + new_files = [] + for challenge_file in self["files"]: + new_files.append(("file", open(self.challenge_directory / challenge_file, mode="rb"))) + + files_payload = {"challenge_id": self.challenge_id, "type": "challenge"} + # Specifically use data= here instead of json= to send multipart/form-data + r = self.api.post("/api/v1/files", files=new_files, data=files_payload) + r.raise_for_status() + + for file_payload in new_files: + file_payload[1].close() + + # Hint delete/create + def _delete_existing_hints(self): + remote_hints = self.api.get("/api/v1/hints").json()["data"] + for hint in remote_hints: + if hint["challenge_id"] == self.challenge_id: + r = self.api.delete(f"/api/v1/hints/{hint['id']}") + r.raise_for_status() + + def _create_hints(self): + for hint in self["hints"]: + if type(hint) == str: + hint_payload = { + "content": hint, + "cost": 0, + "challenge_id": self.challenge_id, + } + else: + hint_payload = { + "content": hint["content"], + "cost": hint["cost"], + "challenge_id": self.challenge_id, + } + + r = self.api.post("/api/v1/hints", json=hint_payload) + r.raise_for_status() + + # Required challenges + def _set_required_challenges(self): + remote_challenges = self.load_installed_challenges() + required_challenges = [] + + for required_challenge in self["requirements"]: + if type(required_challenge) == str: + # requirement by name + # find the challenge id from installed challenges + for remote_challenge in remote_challenges: + if remote_challenge["name"] == required_challenge: + required_challenges.append(remote_challenge["id"]) + + elif type(required_challenge) == int: + # requirement by challenge id + # trust it and use it directly + required_challenges.append(required_challenge) + + required_challenge_ids = list(set(required_challenges)) + + if self.challenge_id in required_challenge_ids: + click.secho( + "Challenge cannot require itself. Skipping invalid requirement.", + fg="yellow", + ) + required_challenges.remove(self.challenge_id) + + requirements_payload = {"requirements": {"prerequisites": required_challenges}} + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=requirements_payload) + r.raise_for_status() + + def sync(self, ignore=()) -> None: + # alias self as challenge for accessing internal dict data + challenge = self + + if "name" in ignore: + click.secho( + "Attribute 'name' cannot be ignored when syncing a challenge", + fg="yellow", + ) + + if not self.get("name"): + raise InvalidChallengeFile("Challenge does not provide a name") + + if challenge.get("files", False) and "files" not in ignore: + # _validate_files will raise if file is not found + self._validate_files() + + challenge_payload = self._get_initial_challenge_payload(ignore=ignore) + remote_challenges = self.load_installed_challenges() + + if not remote_challenges: + raise RemoteChallengeNotFound("Could not load any remote challenges") + + # get challenge id from the remote + for inspected_challenge in remote_challenges: + if inspected_challenge["name"] == challenge["name"]: + self.challenge_id = inspected_challenge["id"] + break + + # return if we failed to determine the challenge id (failed to find the challenge) + if self.challenge_id is None: + raise RemoteChallengeNotFound(f"Could not load remote challenge with name '{challenge['name']}'") + + # remote challenge should exist now + remote_challenge = self.load_installed_challenge(self.challenge_id) + + # if value, category, type or description are ignored, revert them to the remote state in the initial payload + reset_properties_if_ignored = ["value", "category", "type", "description"] + for p in reset_properties_if_ignored: + if p in ignore: + challenge_payload[p] = remote_challenge[p] + + if not self.api: + self.api = API() + + # Update simple properties + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=challenge_payload) + r.raise_for_status() + + # Update flags + if "flags" not in ignore: + self._delete_existing_flags() + if challenge.get("flags"): + self._create_flags() + + # Update topics + if "topics" not in ignore: + self._delete_existing_topics() + if challenge.get("topics"): + self._create_topics() + + # Update tags + if "tags" not in ignore: + self._delete_existing_tags() + if challenge.get("tags"): + self._create_tags() + + # Create / Upload files + if "files" not in ignore: + self._delete_existing_files() + if challenge.get("files"): + self._create_files() + + # Update hints + if "hints" not in ignore: + self._delete_existing_hints() + if challenge.get("hints"): + self._create_hints() + + # Update requirements + if challenge.get("requirements") and "requirements" not in ignore: + self._set_required_challenges() + + make_challenge_visible = False + + # Bring back the challenge to be visible if: + # 1. State is not ignored and set to visible, or defaults to visible + if "state" not in ignore: + if challenge.get("state", "visible") == "visible": + make_challenge_visible = True + # 2. State is ignored, but regardless of the local value, the remote state was visible + else: + if remote_challenge.get("state") == "visible": + make_challenge_visible = True + + if make_challenge_visible: + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json={"state": "visible"}) + r.raise_for_status() + + def create(self, ignore=()) -> None: + # alias self as challenge for accessing internal dict data + challenge = self + + for attr in ["name", "value"]: + if attr in ignore: + click.secho( + f"Attribute '{attr}' cannot be ignored when creating a challenge", + fg="yellow", + ) + + if not challenge.get("name", False): + raise InvalidChallengeDefinition("Challenge does not provide a name") + + if not challenge.get("value", False) and challenge.get("type", "standard") != "dynamic": + raise InvalidChallengeDefinition("Challenge does not provide a value") + + if challenge.get("files", False) and "files" not in ignore: + # _validate_files will raise if file is not found + self._validate_files() + + challenge_payload = self._get_initial_challenge_payload(ignore=ignore) + + # in the case of create value and type can't be ignored: + # value is required (unless the challenge is a dynamic value challenge), and type will default to standard + # if category or description are ignored, set them to an empty string + reset_properties_if_ignored = ["category", "description"] + for p in reset_properties_if_ignored: + if p in ignore: + challenge_payload[p] = "" + + if not self.api: + self.api = API() + + r = self.api.post("/api/v1/challenges", json=challenge_payload) + r.raise_for_status() + + self.challenge_id = r.json()["data"]["id"] + + # Create flags + if challenge.get("flags") and "flags" not in ignore: + self._create_flags() + + # Create topics + if challenge.get("topics") and "topics" not in ignore: + self._create_topics() + + # Create tags + if challenge.get("tags") and "tags" not in ignore: + self._create_tags() + + # Upload files + if challenge.get("files") and "files" not in ignore: + self._create_files() + + # Add hints + if challenge.get("hints") and "hints" not in ignore: + self._create_hints() + + # Add requirements + if challenge.get("requirements") and "requirements" not in ignore: + self._set_required_challenges() + + # Bring back the challenge if it's supposed to be visible + # Either explicitly, or by assuming the default value (possibly because the state is ignored) + if challenge.get("state", "visible") == "visible" or "state" in ignore: + r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json={"state": "visible"}) + r.raise_for_status() + + def lint(self, skip_hadolint=False, flag_format="flag{") -> bool: + challenge = self + + issues = {"fields": [], "dockerfile": [], "hadolint": [], "files": []} + + # Check if required fields are present + for field in ["name", "author", "category", "description", "value"]: + # value is allowed to be none, if the challenge type is dynamic + if field == "value" and challenge.get("type") == "dynamic": + continue + + if challenge.get(field) is None: + issues["fields"].append(f"challenge.yml is missing required field: {field}") + + # Check that the image field and Dockerfile match + if (self.challenge_directory / "Dockerfile").is_file() and challenge.get("image", "") != ".": + issues["dockerfile"].append("Dockerfile exists but image field does not point to it") + + # Check that Dockerfile exists and is EXPOSE'ing a port + if challenge.get("image") == ".": + dockerfile_path = self.challenge_directory / "Dockerfile" + has_dockerfile = dockerfile_path.is_file() + + if not has_dockerfile: + issues["dockerfile"].append("Dockerfile specified in 'image' field but no Dockerfile found") + + if has_dockerfile: + with open(dockerfile_path, "r") as dockerfile: + dockerfile_source = dockerfile.read() + + if "EXPOSE" not in dockerfile_source: + issues["dockerfile"].append("Dockerfile is missing EXPOSE") + + if not skip_hadolint: + # Check Dockerfile with hadolint + hadolint = subprocess.run( + ["docker", "run", "--rm", "-i", "hadolint/hadolint"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + input=dockerfile_source.encode(), + ) + + if hadolint.returncode != 0: + issues["hadolint"].append(hadolint.stdout.decode()) + + else: + click.secho("Skipping Hadolint", fg="yellow") + + # Check that all files exists + challenge_files = challenge.get("files", []) + for challenge_file in challenge_files: + challenge_file_path = self.challenge_directory / challenge_file + + if challenge_file_path.is_file() is False: + issues["files"].append( + f"Challenge file '{challenge_file}' specified, but not found at {challenge_file_path}" + ) + + # Check that files don't have a flag in them + challenge_files = challenge.get("files", []) + for challenge_file in challenge_files: + challenge_file_path = self.challenge_directory / challenge_file + + if not challenge_file_path.exists(): + # the check for files present is above, this is only to look for flags in files that we do have + continue + + for s in strings(challenge_file_path): + if flag_format in s: + s = s.strip() + issues["files"].append(f"Potential flag found in distributed file '{challenge_file}':\n {s}") + + if any(messages for messages in issues.values() if len(messages) > 0): + raise LintException(issues=issues) + + return True diff --git a/ctfcli/core/config.py b/ctfcli/core/config.py new file mode 100644 index 0000000..3e68d67 --- /dev/null +++ b/ctfcli/core/config.py @@ -0,0 +1,112 @@ +import configparser +import json +import os +from pathlib import Path + +import appdirs + +from ctfcli import __name__ as pkg_name +from ctfcli.core.exceptions import ProjectNotInitialized + + +class Config: + def __init__(self): + self.base_path = self.get_base_path() + self.project_path = self.get_project_path() + self.config_path = self.project_path / ".ctf" / "config" + self.data_path = self.get_data_path() + self.templates_path = self.get_templates_path() + self.pages_path = self.get_pages_path() + self.plugins_path = self.get_plugins_path() + + parser = configparser.ConfigParser() + parser.optionxform = str + parser.read(self.config_path) + + self.config = parser + self.challenges = dict(self.config["challenges"]) + + def __getitem__(self, key): + return self.config[key] + + def __contains__(self, key): + return key in self.config + + def write(self, file_handle): + return self.config.write(file_handle) + + def as_json(self, pretty=False) -> str: + data = {} + for section in self.config.sections(): + data[section] = {} + for k, v in self.config.items(section): + data[section][k] = v + + if pretty: + return json.dumps(data, sort_keys=True, indent=4) + + return json.dumps(data) + + @staticmethod + def get_project_path() -> Path: + pwd = Path.cwd() + while pwd != Path("/"): + config = pwd / ".ctf" / "config" + if config.is_file(): + return pwd + pwd = pwd.parent + + raise ProjectNotInitialized + + @staticmethod + def get_config_path() -> Path: + return Config.get_project_path() / ".ctf" / "config" + + @staticmethod + def get_base_path() -> Path: + return Path(__file__).parent.parent + + @staticmethod + def get_data_path() -> Path: + return Path(appdirs.user_data_dir(appname=pkg_name)) + + @staticmethod + def get_pages_path() -> Path: + pages_path = Config.get_project_path() / "pages" + + if not pages_path.exists(): + pages_path.mkdir() + + return pages_path + + @staticmethod + def get_templates_path() -> Path: + templates_path = Config.get_data_path() / "templates" + + if not templates_path.exists(): + templates_path.mkdir(parents=True) + + return templates_path + + @staticmethod + def get_plugins_path() -> Path: + if os.getenv("CTFCLI_PLUGIN_PATH"): + plugins_path = Config._get_custom_plugin_path() + else: + plugins_path = Config.get_data_path() / "plugins" + + if not plugins_path.exists(): + plugins_path.mkdir(parents=True) + + return plugins_path + + @staticmethod + def _get_custom_plugin_path() -> Path: + # Assumes CTFCLI_PLUGIN_PATH is present + custom_plugins_path = Path(os.getenv("CTFCLI_PLUGIN_PATH")) + + if custom_plugins_path.is_absolute(): + return custom_plugins_path + + # If path is relative, assume it's to ctfcli base_path (development convenience) + return Config.get_base_path() / custom_plugins_path diff --git a/ctfcli/core/deployment/__init__.py b/ctfcli/core/deployment/__init__.py new file mode 100644 index 0000000..286ab88 --- /dev/null +++ b/ctfcli/core/deployment/__init__.py @@ -0,0 +1,20 @@ +from typing import Dict, Type + +from ctfcli.core.deployment.base import DeploymentHandler +from ctfcli.core.deployment.cloud import CloudDeploymentHandler +from ctfcli.core.deployment.registry import RegistryDeploymentHandler +from ctfcli.core.deployment.ssh import SSHDeploymentHandler + +DEPLOYMENT_HANDLERS: Dict[str, Type[DeploymentHandler]] = { + "cloud": CloudDeploymentHandler, + "ssh": SSHDeploymentHandler, + "registry": RegistryDeploymentHandler, +} + + +def get_deployment_handler(name: str) -> Type[DeploymentHandler]: + return DEPLOYMENT_HANDLERS[name] + + +def register_deployment_handler(name: str, handler: Type[DeploymentHandler]): + DEPLOYMENT_HANDLERS[name] = handler diff --git a/ctfcli/core/deployment/base.py b/ctfcli/core/deployment/base.py new file mode 100644 index 0000000..1cd005f --- /dev/null +++ b/ctfcli/core/deployment/base.py @@ -0,0 +1,27 @@ +from typing import Optional + +from ctfcli.core.challenge import Challenge + + +class DeploymentResult: + def __init__( + self, + success: bool, + domain: Optional[str] = None, + port: Optional[str] = None, + connection_info: Optional[str] = None, + ): + self.success = success + self.domain = domain + self.port = port + self.connection_info = connection_info + + +class DeploymentHandler: + def __init__(self, challenge: Challenge, host: Optional[str] = None, protocol: Optional[str] = None): + self.challenge = challenge + self.host = host if host else challenge.get("host", None) + self.protocol = protocol if protocol else challenge.get("protocol", None) + + def deploy(self, *args, **kwargs) -> DeploymentResult: + raise NotImplementedError diff --git a/ctfcli/core/deployment/cloud.py b/ctfcli/core/deployment/cloud.py new file mode 100644 index 0000000..efc9cee --- /dev/null +++ b/ctfcli/core/deployment/cloud.py @@ -0,0 +1,198 @@ +import logging +import subprocess +import time +from typing import Dict, Optional +from urllib.parse import urlparse + +import click + +from ctfcli.core.api import API +from ctfcli.core.config import Config +from ctfcli.core.deployment.base import DeploymentHandler, DeploymentResult + +log = logging.getLogger("ctfcli.core.deployment.cloud") + + +class CloudDeploymentHandler(DeploymentHandler): + def __init__(self, *args, **kwargs): + super(CloudDeploymentHandler, self).__init__(*args, **kwargs) + + self.api = API() + self.config = Config() + + # Do not fail here if challenge does not provide an image + # rather return a failed deployment result during deploy + if self.challenge.get("image"): + self.image_name = self.challenge.image.name + + def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult: + # Check whether challenge defines image + if not self.challenge.get("image"): + click.secho("Challenge does not define an image to deploy", fg="red") + return DeploymentResult(False) + + # Check whether instance supports cloud deployments + check = self.api.get("/api/v1/images") + if not check.ok: + click.secho("Target instance does not support cloud deployments", fg="red") + return DeploymentResult(False) + + # Get or create Image in CTFd + image_data = self._get_or_create_image() + + # Build new / initial version of the image + image_name = self.challenge.image.build() + if not image_name: + click.secho("Could not build the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + + if skip_login: + click.secho( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ) + else: + login_result = self._registry_login() + + if not login_result: + click.secho( + "Could not log in to the registry. Please check your access token and instance URL", fg="red" + ) + return DeploymentResult(False) + + push_result = self.challenge.image.push(image_data["location"]) + if not push_result: + click.secho("Could not push image to the registry.", fg="red") + + if skip_login: + click.secho( + "Remember that you need to manually login to the docker registry when using --skip-login", + fg="yellow", + ) + + return DeploymentResult(False) + + # Get or create Service in CTFd + service_data = self._get_or_create_service(image_data["location"]) + + deployed_service_data = self._await_service_deployment(service_data) + if not deployed_service_data: + return DeploymentResult(False) + + # Expose TCP port if configured + if self.protocol == "tcp": + self.api.patch(f"/api/v1/services/{deployed_service_data['id']}", json={"expose": True}).raise_for_status() + deployed_service_data = self.api.get(f"/api/v1/services/{deployed_service_data['id']}").json()["data"] + + connection_info = self._get_connection_info( + hostname=deployed_service_data["hostname"], + tcp_hostname=deployed_service_data.get("tcp_hostname"), + tcp_port=deployed_service_data.get("tcp_port"), + ) + + return DeploymentResult(True, connection_info=connection_info) + + def _get_or_create_image(self): + # Check if image already exists + existing_images = self.api.get("/api/v1/images").json()["data"] + for image_data in existing_images: + if image_data["location"].endswith(self.image_name): + return image_data + + # Create the image if it doesn't exist + return self.api.post("/api/v1/images", json={"name": self.image_name}).json()["data"] + + def _get_or_create_service(self, image_location: str): + existing_services = self.api.get("/api/v1/services").json()["data"] + for service_data in existing_services: + if service_data["name"] == self.image_name: + # Update the existing service image information + self.api.patch( + f"/api/v1/services/{service_data['id']}", + json={"image": image_location}, + ).raise_for_status() + return self.api.get(f"/api/v1/services/{service_data['id']}").json()["data"] + + # Create the service if it doesn't exist + return self.api.post("/api/v1/services", json={"name": self.image_name, "image": image_location}).json()["data"] + + def _await_service_deployment(self, service_data, interval=10, timeout=180) -> Optional[Dict]: + service_id = service_data["id"] + + base_timeout = timeout + i = 0 + while service_data.get("hostname") is None and timeout > 0: + click.secho( + f"Awaiting service deployment [{i * interval}/{base_timeout}s]", + fg="yellow", + ) + service_data = self.api.get(f"/api/v1/services/{service_id}").json()["data"] + + i += 1 + timeout -= interval + time.sleep(interval) + + if timeout == 0: + click.secho("Timeout awaiting challenge deployment", fg="red") + return + + return service_data + + def _registry_login(self, registry: str = "registry.ctfd.io") -> bool: + r = self.api.get("/api/v1/users/me") + r.raise_for_status() + data = r.json() + + if not data["success"]: + return False + + # build registry username: admin@instance.ctfd.io + username = data["data"]["name"] + hostname = urlparse(self.api.prefix_url).hostname + + # require instance url to be ctfd assigned for now + # later this could use dig to resolve a cname record + if not hostname or not str(hostname).endswith(".ctfd.io"): + click.secho( + "Instance URL is not a CTFd assigned URL. Either use the CTFd assigned domain name, " + "or login to the registry manually and deploy with --skip-login", + fg="red", + ) + return False + + docker_login = f"{username}@{hostname}" + docker_password = self.api.access_token.encode() + docker_login_command = [ + "docker", + "login", + "-u", + docker_login, + "--password-stdin", + registry, + ] + + log.debug(f"call({docker_login_command}, stderr=subprocess.PIPE, input=docker_password)") + login_response = subprocess.check_output(docker_login_command, input=docker_password, stderr=subprocess.PIPE) + + if b"Login Succeeded" in login_response: + return True + + return False + + def _get_connection_info( + self, + hostname: str, + tcp_hostname: Optional[str] = None, + tcp_port: Optional[str] = None, + ) -> str: + # if protocol is http(s) - return an URL + if self.protocol and self.protocol.startswith("http"): + # cloud deployments always deploy on https + return f"https://{hostname}" + + # if protocol is tcp, and connection details are provided - return netcat connection string + if self.protocol and self.protocol == "tcp" and tcp_hostname and tcp_port: + return f"nc {tcp_hostname} {tcp_port}" + + # Otherwise return plain hostname + return hostname diff --git a/ctfcli/core/deployment/registry.py b/ctfcli/core/deployment/registry.py new file mode 100644 index 0000000..b5d5460 --- /dev/null +++ b/ctfcli/core/deployment/registry.py @@ -0,0 +1,98 @@ +import logging +import subprocess +from urllib.parse import urlparse + +import click + +from ctfcli.core.config import Config +from ctfcli.core.deployment.base import DeploymentHandler, DeploymentResult + +log = logging.getLogger("ctfcli.core.deployment.registry") + + +class RegistryDeploymentHandler(DeploymentHandler): + def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult: + config = Config() + + # Check whether challenge defines image + if not self.challenge.get("image"): + click.secho("Challenge does not define an image to deploy", fg="red") + return DeploymentResult(False) + + if not self.host: + click.secho( + "No host provided for the deployment. Use --host, or define host in the challenge.yml file", fg="red" + ) + return DeploymentResult(False) + + # resolve a location for the image push + # e.g. registry.example.com/test-project/challenge-image-name + # challenge image name is appended to the host provided for the deployment + host_url = urlparse(self.host) + location = f"{host_url.netloc}{host_url.path.rstrip('/')}/{self.challenge.image.name}" + + if skip_login: + click.secho( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ) + else: + if "registry" not in config or not config["registry"]: + click.secho("Config does not provide a registry section.", fg="red") + return DeploymentResult(False) + + registry_username = config["registry"].get("username") + registry_password = config["registry"].get("password") + if not registry_username or not registry_password: + click.secho("Config is missing credentials for the registry.", fg="red") + return DeploymentResult(False) + + login_result = self._registry_login( + registry_username, + registry_password, + host_url.netloc, + ) + + if not login_result: + click.secho("Could not log in to the registry. Please check your configured credentials.", fg="red") + return DeploymentResult(False) + + build_result = self.challenge.image.build() + if not build_result: + click.secho("Could not build the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + + push_result = self.challenge.image.push(location) + if not push_result: + click.secho("Could not push image to the registry. Please check docker output above.", fg="red") + + if skip_login: + click.secho( + "Remember that you need to manually login to the docker registry when using --skip-login", + fg="yellow", + ) + + return DeploymentResult(False) + + # In this deployment result we can't really provide more data such as the connection info, as we don't know + # where and how the service is deployed - only that it's pushed to a registry + return DeploymentResult(True) + + def _registry_login(self, username: str, password: str, registry: str): + docker_login_command = [ + "docker", + "login", + "-u", + username, + "--password-stdin", + registry, + ] + + try: + log.debug(f"call({docker_login_command}, stderr=subprocess.PIPE, input=password)") + subprocess.run( + docker_login_command, input=password.encode(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except subprocess.CalledProcessError: + return False + return True diff --git a/ctfcli/core/deployment/ssh.py b/ctfcli/core/deployment/ssh.py new file mode 100644 index 0000000..9c40a3e --- /dev/null +++ b/ctfcli/core/deployment/ssh.py @@ -0,0 +1,95 @@ +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +import click + +from ctfcli.core.deployment.base import DeploymentHandler, DeploymentResult + + +class SSHDeploymentHandler(DeploymentHandler): + def deploy(self, *args, **kwargs) -> DeploymentResult: + if not self.challenge.get("image"): + click.secho("Challenge does not define an image to deploy", fg="red") + return DeploymentResult(False) + + if not self.host: + click.secho( + "No host provided for the deployment. Use --host, or define host in the challenge.yml file", fg="red" + ) + return DeploymentResult(False) + + build_result = self.challenge.image.build() + if not build_result: + click.secho("Could not build the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + + image_name = self.challenge.image.name + export_result = self.challenge.image.export() + if not export_result: + click.secho("Could not export the image. Please check docker output above.", fg="red") + return DeploymentResult(False) + + image_export_path = Path(export_result) + host_url = urlparse(self.host) + target_path = host_url.path or "/tmp" + target_file = f"{target_path}/{image_export_path.name}" + + exposed_port = self.challenge.image.get_exposed_port() + if not exposed_port: + click.secho("Could not resolve a port to expose. Make sure your Dockerfile EXPOSE's a port.", fg="red") + return DeploymentResult(False) + + target_hostname = host_url.netloc[host_url.netloc.find("@") + 1 :] + try: + subprocess.run(["scp", image_export_path, f"{host_url.netloc}:{target_file}"]) + subprocess.run( + [ + "ssh", + host_url.netloc, + f"docker load -i {target_file} && rm {target_file}", + ] + ) + subprocess.run( + [ + "ssh", + host_url.netloc, + f"docker stop {image_name} 2>/dev/null; docker rm {image_name} 2>/dev/null", + ] + ) + subprocess.run( + [ + "ssh", + host_url.netloc, + f"docker run -d -p{exposed_port}:{exposed_port} --name {image_name} --restart always {image_name}", + ] + ) + + except subprocess.CalledProcessError as e: + click.secho("Failed to deploy image!", fg="red") + click.secho(str(e), fg="red") + return DeploymentResult(False) + + image_export_path.unlink() + + connection_info = self._get_connection_info(target_hostname, exposed_port) + return DeploymentResult(True, target_hostname, exposed_port, connection_info) + + def _get_connection_info(self, hostname: str, port: int) -> str: + # if protocol is http(s) - return an URL + if self.protocol and self.protocol.startswith("http"): + # if port is the default for http or https don't include a port + if port == 80 and self.protocol == "http": + return f"http://{hostname}" + + if port == 443 and self.protocol == "https": + return f"https://{hostname}" + + return f"{self.protocol}://{hostname}:{port}" + + # if protocol is tcp, and connection details are provided - return netcat connection string + if self.protocol and self.protocol == "tcp": + return f"nc {hostname} {port}" + + # Otherwise return plain hostname + return hostname diff --git a/ctfcli/core/exceptions.py b/ctfcli/core/exceptions.py new file mode 100644 index 0000000..1acfd35 --- /dev/null +++ b/ctfcli/core/exceptions.py @@ -0,0 +1,57 @@ +from typing import Dict, List + +import click + + +class ProjectNotInitialized(Exception): + pass + + +class ChallengeException(Exception): + pass + + +class InvalidChallengeDefinition(ChallengeException): + pass + + +class InvalidChallengeFile(ChallengeException): + pass + + +class RemoteChallengeNotFound(ChallengeException): + pass + + +class LintException(Exception): + def __init__(self, *args, issues: Dict[str, List[str]] = None): + self.issues = issues if issues else {} + super(LintException, self).__init__(*args) + + def print_summary(self): + for category, issues in self.issues.items(): + if len(issues) > 0: + click.secho(f"{category.capitalize()}:", fg="yellow") + + prefix = " - " if category != "hadolint" else "" + for issue in issues: + click.echo(f"{prefix}{issue}") + + if category != "hadolint": + click.echo() + + +class PageException(Exception): + pass + + +class InvalidPageFormat(PageException): + pass + + +class InvalidPageConfiguration(PageException): + pass + + +class IllegalPageOperation(PageException): + pass diff --git a/ctfcli/core/image.py b/ctfcli/core/image.py new file mode 100644 index 0000000..7028203 --- /dev/null +++ b/ctfcli/core/image.py @@ -0,0 +1,64 @@ +import json +import subprocess +import tempfile +from os import PathLike +from pathlib import Path +from typing import Optional, Union + + +class Image: + def __init__(self, name: str, build_path: Union[str, PathLike]): + self.name = name + self.build_path = Path(build_path) + self.built = False + + def build(self) -> Optional[str]: + docker_build = subprocess.call(["docker", "build", "-t", self.name, "."], cwd=self.build_path.absolute()) + if docker_build != 0: + return + + self.built = True + return self.name + + def push(self, location: str) -> Optional[str]: + if not self.built: + self.build() + + docker_tag = subprocess.call(["docker", "tag", self.name, location]) + docker_push = subprocess.call(["docker", "push", location]) + + if any(r != 0 for r in [docker_tag, docker_push]): + return + + return location + + def export(self) -> Optional[str]: + if not self.built: + self.build() + + image_tar = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{self.name}.docker.tar") + docker_save = subprocess.call(["docker", "save", "--output", image_tar.name, self.name]) + + if docker_save != 0: + return + + return image_tar.name + + def get_exposed_port(self) -> Optional[str]: + if not self.built: + self.build() + + try: + docker_output = subprocess.check_output( + ["docker", "inspect", "--format={{json .Config.ExposedPorts}}", self.name] + ) + except subprocess.CalledProcessError: + return + + ports_data = json.loads(docker_output) + if ports_data: + ports = list(ports_data.keys()) + + if ports: + # Split '2323/tcp' + return ports[0].split("/")[0] diff --git a/ctfcli/core/page.py b/ctfcli/core/page.py new file mode 100644 index 0000000..67e4523 --- /dev/null +++ b/ctfcli/core/page.py @@ -0,0 +1,238 @@ +from os import PathLike +from pathlib import Path +from typing import Dict, List, Optional, Union + +try: + from typing import Self +except ImportError: + from typing_extensions import Self + +import click +import frontmatter + +from ctfcli.core.api import API +from ctfcli.core.config import Config +from ctfcli.core.exceptions import ( + IllegalPageOperation, + InvalidPageConfiguration, + InvalidPageFormat, +) + +PAGE_FORMATS = { + ".md": "markdown", + ".html": "html", + ".htm": "html", +} + + +# Note: +# This class cannot delete pages yet - which can lead to expected, but perhaps unintuitive behavior with sync +# When changing a route of a page, it will not be possible to sync it as we will not be able to determine an existing +# page to update (that is done with the route). +# The page can be pushed, but that will just create another page at the new route - which can then be reflected locally +# by using pull. The old page has to be deleted manually or hidden with sync and hidden: true + + +class Page: + _remote_pages: Optional[List[Self]] = None + _remote_page_ids: Optional[Dict[str, int]] = None + + def __init__(self, page_path: Optional[Union[str, PathLike]] = None, page_id: Optional[int] = None): + # single page object can only be created as either local or remote at the moment + # this can be changed later to allow a merge-like behavior + if not page_path and not page_id or page_path and page_id: + raise InvalidPageConfiguration + + self.api = API() + + if page_id: + # if page is remote - it can only be used for downloading + self.page_id = page_id + page_data = self._get_data_by_id() + self.format = page_data["format"] + + # if page is remote we have to infer a local file name + # default to route.ext which also works for nesting: nested/route.ext + self.page_file_path = f"{page_data['route']}{self.get_format_extension(self.format)}" + else: + # if page is local - it can be used for sync, upload and download (with overwrite) + self.page_path = Path(page_path) + page_data = self._get_data_by_path() + self.format = self.get_format(self.page_path.suffix) + + # check if page is also remote, so that we can potentially sync + remote_page_id = self.get_remote_page_id(page_data["route"]) + if remote_page_id: + self.page_id = remote_page_id + + # if page is local we can know the page file path + # which should be output as relative to the pages directory + self.page_file_path = str(self.page_path.relative_to(Config.get_pages_path())) + + self.route = page_data["route"] + self.title = page_data["title"] + self.content = page_data["content"] + self.is_draft = bool(page_data.get("draft", False)) + self.is_hidden = bool(page_data.get("hidden", False)) + self.is_auth_required = bool(page_data.get("auth_required", False)) + + def __str__(self): + return self.page_file_path + + def _get_data_by_path(self) -> Optional[Dict]: + if not self.page_path.exists(): + return + + with open(self.page_path, "r") as page_file: + page_data = frontmatter.load(page_file) + + return {**page_data.metadata, "content": page_data.content} + + def _get_data_by_id(self) -> Optional[Dict]: + r = self.api.get(f"/api/v1/pages/{self.page_id}") + + if not r.ok: + return + + return r.json()["data"] + + def as_dict(self): + return { + "route": self.route, + "title": self.title, + "content": self.content, + "draft": self.is_draft, + "hidden": self.is_hidden, + "auth_required": self.is_auth_required, + "format": self.format, + } + + def as_frontmatter_post(self) -> frontmatter.Post: + metadata = { + "route": self.route, + "title": self.title, + "draft": self.is_draft, + "hidden": self.is_hidden, + "auth_required": self.is_auth_required, + } + return frontmatter.Post(self.content, **metadata) + + def sync(self): + # sync / update remote copy with local state + if not getattr(self, "page_id", None): + raise IllegalPageOperation( + f"Cannot sync page '{self.page_file_path}' - remote version does not exists. Use push first." + ) + + if not getattr(self, "page_path", None): + raise IllegalPageOperation( + f"Cannot sync page '{self.page_file_path}' - local version does not exists. Use pull first." + ) + + r = self.api.patch(f"/api/v1/pages/{self.page_id}", json=self.as_dict()) + r.raise_for_status() + + def pull(self, overwrite=False): + # download / create local copy of a remote page + # download without overwrite is useful only for initial pull + if not getattr(self, "page_id", None): + raise IllegalPageOperation(f"Cannot pull page '{self.page_file_path}' - remote version does not exists.") + + page_path = Config.get_pages_path() / self.page_file_path + + # make sure all necessary directories exist + page_path.parent.mkdir(parents=True, exist_ok=True) + + if page_path.is_file(): + if not overwrite: + raise IllegalPageOperation(f"Cannot pull page '{self.page_file_path}' - file already exists.") + + click.secho(f"Overwriting page file '{self.page_file_path}'", fg="yellow") + + with open(page_path, "wb+") as page_file: + frontmatter.dump(self.as_frontmatter_post(), page_file) + + def push(self): + # upload / create remote copy of a local page + if getattr(self, "page_id", None): + raise IllegalPageOperation( + f"Cannot push page '{self.page_file_path}' - remote version exists. Use sync instead." + ) + + r = self.api.post("/api/v1/pages", json=self.as_dict()) + r.raise_for_status() + + self.page_id = r.json()["data"]["id"] + + @staticmethod + def get_format(ext) -> str: + if ext not in PAGE_FORMATS: + raise InvalidPageFormat + + return PAGE_FORMATS[ext] + + @staticmethod + def get_format_extension(fmt) -> str: + for supported_ext, supported_fmt in PAGE_FORMATS.items(): + if fmt == supported_fmt: + return supported_ext + + raise InvalidPageFormat + + @classmethod + def get_remote_pages(cls) -> List[Self]: + # if we find a saved list of remote pages we can use it + if cls._remote_pages: + return cls._remote_pages + + api = API() + installed_pages = api.get("/api/v1/pages").json()["data"] + + pages = [] + for page in installed_pages: + pages.append(Page(page_id=page["id"])) + + # save remote pages for reuse + cls._remote_pages = pages + return pages + + @classmethod + def get_remote_page_id(cls, route: str) -> Optional[int]: + # if we find a saved cache, and the route has a page_id associated - we can return it + if cls._remote_page_ids and route in cls._remote_page_ids: + return cls._remote_page_ids[route] + + # if we find a saved list of remote pages instead, we can also use it + if cls._remote_pages: + for page in cls._remote_pages: + if route == page.route: + return page.page_id + + # otherwise we just take the result of listing all pages to search for the id + # and build the lookup cache for further use + api = API() + remote_pages = api.get("/api/v1/pages").json()["data"] + + remote_page_ids = {} + for page in remote_pages: + remote_page_ids[page["route"]] = page["id"] + + cls._remote_page_ids = remote_page_ids + + # finally return the page id from the lookup dict, if the route has been found + return cls._remote_page_ids.get(route, None) + + @classmethod + def get_local_pages(cls) -> List[Self]: + config = Config() + pages_dir = config.get_pages_path() + + page_files = [] + for supported_ext in PAGE_FORMATS.keys(): + page_files.extend(list(pages_dir.glob(f"**/*{supported_ext}"))) + + pages = [] + for page_path in page_files: + pages.append(Page(page_path=page_path)) + + return pages diff --git a/ctfcli/core/plugins.py b/ctfcli/core/plugins.py new file mode 100644 index 0000000..4829493 --- /dev/null +++ b/ctfcli/core/plugins.py @@ -0,0 +1,23 @@ +import importlib +import logging +import sys +from typing import Dict + +from ctfcli.core.config import Config + +log = logging.getLogger("ctfcli.core.plugins") + + +def load_plugins(commands: Dict): + plugins_path = Config.get_plugins_path() + sys.path.insert(0, str(plugins_path.absolute())) + + for plugin in sorted(plugins_path.iterdir()): + plugin_path = plugins_path / plugin / "__init__.py" + + log.debug(f"Loading plugin '{plugin}' from '{plugin_path}'") + + loaded = importlib.import_module(plugin.stem) + loaded.load(commands) + + sys.path.remove(str(plugins_path.absolute())) diff --git a/ctfcli/templates/crypto/default/cookiecutter.json b/ctfcli/templates/crypto/default/cookiecutter.json index e8b089c..9fbc311 100644 --- a/ctfcli/templates/crypto/default/cookiecutter.json +++ b/ctfcli/templates/crypto/default/cookiecutter.json @@ -1,3 +1,3 @@ { - "name": "Hello" + "name": "crypto-challenge" } \ No newline at end of file diff --git a/ctfcli/templates/web/default/{{cookiecutter.name}}/src/app.py b/ctfcli/templates/web/default/{{cookiecutter.name}}/src/app.py index 559a418..aa12ee5 100755 --- a/ctfcli/templates/web/default/{{cookiecutter.name}}/src/app.py +++ b/ctfcli/templates/web/default/{{cookiecutter.name}}/src/app.py @@ -3,9 +3,8 @@ from flask import Flask, redirect, render_template, request, session, url_for from flask_bootstrap import Bootstrap -from sqlalchemy.exc import IntegrityError - from models import Users, db, hash_password, verify_password +from sqlalchemy.exc import IntegrityError app = Flask(__name__) app.config.from_object("config.Config") diff --git a/ctfcli/utils/api.py b/ctfcli/utils/api.py deleted file mode 100644 index 1e3f660..0000000 --- a/ctfcli/utils/api.py +++ /dev/null @@ -1,17 +0,0 @@ -from urllib.parse import urljoin - -from requests import Session - - -class APISession(Session): - def __init__(self, prefix_url=None, *args, **kwargs): - super(APISession, self).__init__(*args, **kwargs) - # Strip out ending slashes and append a singular one so we generate - # clean base URLs for both main deployments and subdir deployments - self.prefix_url = prefix_url.rstrip("/") + "/" - - def request(self, method, url, *args, **kwargs): - # Strip out the preceding / so that urljoin creates the right url - # considering the appended / on the prefix_url - url = urljoin(self.prefix_url, url.lstrip("/")) - return super(APISession, self).request(method, url, *args, **kwargs) diff --git a/ctfcli/utils/challenge.py b/ctfcli/utils/challenge.py deleted file mode 100644 index 7878244..0000000 --- a/ctfcli/utils/challenge.py +++ /dev/null @@ -1,408 +0,0 @@ -from pathlib import Path -import subprocess - -import click -import yaml - -from .config import generate_session -from .tools import strings - - -class Yaml(dict): - def __init__(self, data, file_path=None): - super().__init__(data) - self.file_path = Path(file_path) - self.directory = self.file_path.parent - - -def load_challenge(path): - try: - with open(path) as f: - return Yaml(data=yaml.safe_load(f.read()), file_path=path) - except FileNotFoundError: - click.secho(f"No challenge.yml was found in {path}", fg="red") - return - - -def load_installed_challenge(challenge_id): - s = generate_session() - return s.get(f"/api/v1/challenges/{challenge_id}", json=True).json()["data"] - - -def load_installed_challenges(): - s = generate_session() - return s.get("/api/v1/challenges?view=admin", json=True).json()["data"] - - -def sync_challenge(challenge, ignore=[]): - data = { - "name": challenge["name"], - "category": challenge["category"], - "description": challenge["description"], - "type": challenge.get("type", "standard"), - "value": int(challenge["value"]) if challenge["value"] else challenge["value"], - **challenge.get("extra", {}), - } - - # Some challenge types (e.g. dynamic) override value. - # We can't send it to CTFd because we don't know the current value - if challenge["value"] is None: - del challenge["value"] - - if challenge.get("attempts") and "attempts" not in ignore: - data["max_attempts"] = challenge.get("attempts") - - if challenge.get("connection_info") and "connection_info" not in ignore: - data["connection_info"] = challenge.get("connection_info") - - data["state"] = "hidden" - - installed_challenges = load_installed_challenges() - for c in installed_challenges: - if c["name"] == challenge["name"]: - if "category" in ignore: - data["category"] = c["category"] - challenge_id = c["id"] - break - else: - return - - s = generate_session() - - original_challenge = s.get(f"/api/v1/challenges/{challenge_id}", json=data).json()[ - "data" - ] - - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - # Create new flags - if challenge.get("flags") and "flags" not in ignore: - # Delete existing flags - current_flags = s.get("/api/v1/flags", json=data).json()["data"] - for flag in current_flags: - if flag["challenge_id"] == challenge_id: - flag_id = flag["id"] - r = s.delete(f"/api/v1/flags/{flag_id}", json=True) - r.raise_for_status() - for flag in challenge["flags"]: - if type(flag) == str: - data = {"content": flag, "type": "static", "challenge_id": challenge_id} - r = s.post("/api/v1/flags", json=data) - r.raise_for_status() - elif type(flag) == dict: - flag["challenge_id"] = challenge_id - r = s.post("/api/v1/flags", json=flag) - r.raise_for_status() - - # Update topics - if challenge.get("topics") and "topics" not in ignore: - # Delete existing challenge topics - current_topics = s.get( - f"/api/v1/challenges/{challenge_id}/topics", json="" - ).json()["data"] - for topic in current_topics: - topic_id = topic["id"] - r = s.delete( - f"/api/v1/topics?type=challenge&target_id={topic_id}", json=True - ) - r.raise_for_status() - # Add new challenge topics - for topic in challenge["topics"]: - r = s.post( - "/api/v1/topics", - json={ - "value": topic, - "type": "challenge", - "challenge_id": challenge_id, - }, - ) - r.raise_for_status() - - # Update tags - if challenge.get("tags") and "tags" not in ignore: - # Delete existing tags - current_tags = s.get("/api/v1/tags", json=data).json()["data"] - for tag in current_tags: - if tag["challenge_id"] == challenge_id: - tag_id = tag["id"] - r = s.delete(f"/api/v1/tags/{tag_id}", json=True) - r.raise_for_status() - for tag in challenge["tags"]: - r = s.post( - "/api/v1/tags", json={"challenge_id": challenge_id, "value": tag} - ) - r.raise_for_status() - - # Upload files - if challenge.get("files") and "files" not in ignore: - # Delete existing files - all_current_files = s.get("/api/v1/files?type=challenge", json=data).json()[ - "data" - ] - for f in all_current_files: - for used_file in original_challenge["files"]: - if f["location"] in used_file: - file_id = f["id"] - r = s.delete(f"/api/v1/files/{file_id}", json=True) - r.raise_for_status() - files = [] - for f in challenge["files"]: - file_path = Path(challenge.directory, f) - if file_path.exists(): - file_object = ("file", file_path.open(mode="rb")) - files.append(file_object) - else: - click.secho(f"File {file_path} was not found", fg="red") - raise Exception(f"File {file_path} was not found") - - data = {"challenge_id": challenge_id, "type": "challenge"} - # Specifically use data= here instead of json= to send multipart/form-data - r = s.post("/api/v1/files", files=files, data=data) - r.raise_for_status() - - # Create hints - if challenge.get("hints") and "hints" not in ignore: - # Delete existing hints - current_hints = s.get("/api/v1/hints", json=data).json()["data"] - for hint in current_hints: - if hint["challenge_id"] == challenge_id: - hint_id = hint["id"] - r = s.delete(f"/api/v1/hints/{hint_id}", json=True) - r.raise_for_status() - - for hint in challenge["hints"]: - if type(hint) == str: - data = {"content": hint, "cost": 0, "challenge_id": challenge_id} - else: - data = { - "content": hint["content"], - "cost": hint["cost"], - "challenge_id": challenge_id, - } - - r = s.post("/api/v1/hints", json=data) - r.raise_for_status() - - # Update requirements - if challenge.get("requirements") and "requirements" not in ignore: - installed_challenges = load_installed_challenges() - required_challenges = [] - for r in challenge["requirements"]: - if type(r) == str: - for c in installed_challenges: - if c["name"] == r: - required_challenges.append(c["id"]) - elif type(r) == int: - required_challenges.append(r) - - required_challenges = list(set(required_challenges)) - data = {"requirements": {"prerequisites": required_challenges}} - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - # Unhide challenge depending upon the value of "state" in spec - if "state" not in ignore: - data = {"state": "visible"} - if challenge.get("state"): - if challenge["state"] in ["hidden", "visible"]: - data["state"] = challenge["state"] - - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - -def create_challenge(challenge, ignore=[]): - data = { - "name": challenge["name"], - "category": challenge["category"], - "description": challenge["description"], - "type": challenge.get("type", "standard"), - "value": int(challenge["value"]) if challenge["value"] else challenge["value"], - **challenge.get("extra", {}), - } - - # Some challenge types (e.g. dynamic) override value. - # We can't send it to CTFd because we don't know the current value - if challenge["value"] is None: - del challenge["value"] - - if challenge.get("attempts") and "attempts" not in ignore: - data["max_attempts"] = challenge.get("attempts") - - if challenge.get("connection_info") and "connection_info" not in ignore: - data["connection_info"] = challenge.get("connection_info") - - s = generate_session() - - r = s.post("/api/v1/challenges", json=data) - r.raise_for_status() - - challenge_data = r.json() - challenge_id = challenge_data["data"]["id"] - - # Create flags - if challenge.get("flags") and "flags" not in ignore: - for flag in challenge["flags"]: - if type(flag) == str: - data = {"content": flag, "type": "static", "challenge_id": challenge_id} - r = s.post("/api/v1/flags", json=data) - r.raise_for_status() - elif type(flag) == dict: - flag["challenge"] = challenge_id - r = s.post("/api/v1/flags", json=flag) - r.raise_for_status() - - # Create topics - if challenge.get("topics") and "topics" not in ignore: - for topic in challenge["topics"]: - r = s.post( - "/api/v1/topics", - json={ - "value": topic, - "type": "challenge", - "challenge_id": challenge_id, - }, - ) - r.raise_for_status() - - # Create tags - if challenge.get("tags") and "tags" not in ignore: - for tag in challenge["tags"]: - r = s.post( - "/api/v1/tags", json={"challenge_id": challenge_id, "value": tag} - ) - r.raise_for_status() - - # Upload files - if challenge.get("files") and "files" not in ignore: - files = [] - for f in challenge["files"]: - file_path = Path(challenge.directory, f) - if file_path.exists(): - file_object = ("file", file_path.open(mode="rb")) - files.append(file_object) - else: - click.secho(f"File {file_path} was not found", fg="red") - raise Exception(f"File {file_path} was not found") - - data = {"challenge_id": challenge_id, "type": "challenge"} - # Specifically use data= here instead of json= to send multipart/form-data - r = s.post("/api/v1/files", files=files, data=data) - r.raise_for_status() - - # Add hints - if challenge.get("hints") and "hints" not in ignore: - for hint in challenge["hints"]: - if type(hint) == str: - data = {"content": hint, "cost": 0, "challenge_id": challenge_id} - else: - data = { - "content": hint["content"], - "cost": hint["cost"], - "challenge_id": challenge_id, - } - - r = s.post("/api/v1/hints", json=data) - r.raise_for_status() - - # Add requirements - if challenge.get("requirements") and "requirements" not in ignore: - installed_challenges = load_installed_challenges() - required_challenges = [] - for r in challenge["requirements"]: - if type(r) == str: - for c in installed_challenges: - if c["name"] == r: - required_challenges.append(c["id"]) - elif type(r) == int: - required_challenges.append(r) - - required_challenges = list(set(required_challenges)) - data = {"requirements": {"prerequisites": required_challenges}} - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - # Set challenge state - if challenge.get("state") and "state" not in ignore: - data = {"state": "hidden"} - if challenge["state"] in ["hidden", "visible"]: - data["state"] = challenge["state"] - - r = s.patch(f"/api/v1/challenges/{challenge_id}", json=data) - r.raise_for_status() - - -def lint_challenge(path): - try: - challenge = load_challenge(path) - except yaml.YAMLError as e: - click.secho(f"Error parsing challenge.yml: {e}", fg="red") - exit(1) - - required_fields = ["name", "author", "category", "description", "value"] - errors = [] - for field in required_fields: - if field == "value" and challenge.get("type") == "dynamic": - pass - else: - if challenge.get(field) is None: - errors.append(field) - - if len(errors) > 0: - print("Missing fields: ", ", ".join(errors)) - exit(1) - - # Check that the image field and Dockerfile match - if (Path(path).parent / "Dockerfile").is_file() and challenge.get("image") != ".": - print("Dockerfile exists but image field does not point to it") - exit(1) - - # Check that Dockerfile exists and is EXPOSE'ing a port - if challenge.get("image") == ".": - try: - dockerfile = (Path(path).parent / "Dockerfile").open().read() - except FileNotFoundError: - print("Dockerfile specified in 'image' field but no Dockerfile found") - exit(1) - - if "EXPOSE" not in dockerfile: - print("Dockerfile missing EXPOSE") - exit(1) - - # Check Dockerfile with hadolint - proc = subprocess.run( - ["docker", "run", "--rm", "-i", "hadolint/hadolint"], - input=dockerfile.encode(), - ) - if proc.returncode != 0: - print("Hadolint found Dockerfile lint issues, please resolve") - exit(1) - - # Check that all files exists - files = challenge.get("files", []) - errored = False - for f in files: - fpath = Path(path).parent / f - if fpath.is_file() is False: - print(f"File {f} specified but not found at {fpath.absolute()}") - errored = True - if errored: - exit(1) - - # Check that files don't have a flag in them - files = challenge.get("files", []) - errored = False - for f in files: - fpath = Path(path).parent / f - for s in strings(fpath): - # TODO make flag format customizable - if "flag" in s: - print( - f"Potential flag {s} found in distributed file {fpath.absolute()}" - ) - errored = True - if errored: - exit(1) - - exit(0) diff --git a/ctfcli/utils/config.py b/ctfcli/utils/config.py deleted file mode 100644 index b874e55..0000000 --- a/ctfcli/utils/config.py +++ /dev/null @@ -1,88 +0,0 @@ -import configparser -import json -import os - -from ctfcli import __file__ as base_path - -from .api import APISession - - -def get_base_path(): - return os.path.dirname(base_path) - - -def get_config_path(): - pwd = os.getcwd() - while pwd: - config = os.path.join(pwd, ".ctf/config") - if os.path.isfile(config): - return config - new_pwd = os.path.dirname(pwd) - pwd = None if new_pwd == pwd else new_pwd - return None - - -def get_project_path(): - pwd = os.getcwd() - while pwd: - config = os.path.join(pwd, ".ctf/config") - if os.path.isfile(config): - return pwd - new_pwd = os.path.dirname(pwd) - pwd = None if new_pwd == pwd else new_pwd - return None - - -def load_config(): - path = get_config_path() - parser = configparser.ConfigParser() - - # Preserve case in configparser - parser.optionxform = str - - parser.read(path) - return parser - - -def preview_config(as_string=False): - config = load_config() - - d = {} - for section in config.sections(): - d[section] = {} - for k, v in config.items(section): - d[section][k] = v - - preview = json.dumps(d, sort_keys=True, indent=4) - - if as_string is True: - return preview - else: - print(preview) - - -def generate_session(): - config = load_config() - - # Load required configuration values - url = config["config"]["url"] - access_token = config["config"]["access_token"] - - # Handle SSL verification disabling - try: - # Get an ssl_verify config. Default to True if it doesn't exist - ssl_verify = config["config"].getboolean("ssl_verify", True) - except ValueError: - # If we didn't a proper boolean value we should load it as a string - # https://requests.kennethreitz.org/en/master/user/advanced/#ssl-cert-verification - ssl_verify = config["config"].get("ssl_verify") - - s = APISession(prefix_url=url) - s.verify = ssl_verify - s.headers.update({"Authorization": f"Token {access_token}"}) - - # Handle cookies section in config - if "cookies" in config: - s.cookies.update(dict(config["cookies"])) - - return s diff --git a/ctfcli/utils/deploy.py b/ctfcli/utils/deploy.py deleted file mode 100644 index e5809a3..0000000 --- a/ctfcli/utils/deploy.py +++ /dev/null @@ -1,179 +0,0 @@ -import os -import subprocess -import time -import click -from pathlib import Path -from urllib.parse import urlparse -from slugify import slugify -from ctfcli.utils.config import generate_session - -from ctfcli.utils.images import ( - build_image, - export_image, - get_exposed_ports, - push_image, -) - - -def format_connection_info(protocol, hostname, tcp_hostname, tcp_port): - if protocol is None: - connection_info = hostname - elif protocol.startswith("http"): - connection_info = f"{protocol}://{hostname}" - elif protocol == "tcp": - connection_info = f"nc {tcp_hostname} {tcp_port}" - else: - connection_info = hostname - - return connection_info - - -def ssh(challenge, host, protocol): - # Build image - image_name = build_image(challenge=challenge) - print(f"Built {image_name}") - - # Export image to a file - image_path = export_image(challenge=challenge) - print(f"Exported {image_name} to {image_path}") - filename = Path(image_path).name - - # Transfer file to SSH host - print(f"Transferring {image_path} to {host}") - host = urlparse(host) - folder = host.path or "/tmp" - target_file = f"{folder}/{filename}" - exposed_port = get_exposed_ports(challenge=challenge) - domain = host.netloc[host.netloc.find("@") + 1 :] - subprocess.run(["scp", image_path, f"{host.netloc}:{target_file}"]) - subprocess.run( - ["ssh", host.netloc, f"docker load -i {target_file} && rm {target_file}"] - ) - subprocess.run( - [ - "ssh", - host.netloc, - f"docker run -d -p{exposed_port}:{exposed_port} --name {image_name} --restart always {image_name}", - ] - ) - - # Clean up files - os.remove(image_path) - print(f"Cleaned up {image_path}") - - status = True - domain = domain - port = exposed_port - connect_info = format_connection_info( - protocol=protocol, hostname=domain, tcp_hostname=domain, tcp_port=port, - ) - return status, domain, port, connect_info - - -def registry(challenge, host, protocol): - # Build image - image_name = build_image(challenge=challenge) - url = urlparse(host) - tag = f"{url.netloc}{url.path}" - push_image(local_tag=image_name, location=tag) - status = True - domain = "" - port = "" - connect_info = format_connection_info( - protocol=protocol, hostname=domain, tcp_hostname=domain, tcp_port=port, - ) - return status, domain, port, connect_info - - -def cloud(challenge, host, protocol): - name = challenge["name"] - slug = slugify(name) - - s = generate_session() - # Detect whether we have the appropriate endpoints - check = s.get("/api/v1/images", json=True) - if check.ok is False: - click.secho( - "Target instance does not have deployment endpoints", fg="red", - ) - return False, None, None, None - - # Try to find an appropriate image. - images = s.get("/api/v1/images", json=True).json()["data"] - image = None - for i in images: - if i["location"].endswith(f"/{slug}"): - image = i - break - else: - # Create the image if we did not find it. - image = s.post("/api/v1/images", json={"name": slug}).json()["data"] - - # Build image - image_name = build_image(challenge=challenge) - location = image["location"] - - # TODO: Authenticate to Registry - - # Push image - push_image(image_name, location) - - # Look for existing service - services = s.get("/api/v1/services", json=True).json()["data"] - service = None - for srv in services: - if srv["name"] == slug: - service = srv - # Update the service - s.patch( - f"/api/v1/services/{service['id']}", json={"image": location} - ).raise_for_status() - service = s.get(f"/api/v1/services/{service['id']}", json=True).json()[ - "data" - ] - break - else: - # Could not find the service. Create it using our pushed image. - # Deploy the image by creating service - service = s.post( - "/api/v1/services", json={"name": slug, "image": location,}, - ).json()["data"] - - # Get connection details - service_id = service["id"] - service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] - - DEPLOY_TIMEOUT = 180 - while service["hostname"] is None and DEPLOY_TIMEOUT > 0: - click.secho( - "Waiting for challenge hostname", fg="yellow", - ) - service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] - DEPLOY_TIMEOUT -= 10 - time.sleep(10) - - if DEPLOY_TIMEOUT == 0: - click.secho( - "Timeout waiting for challenge to deploy", fg="red", - ) - return False, None, None, None - - # Expose port if we are using tcp - if protocol == "tcp": - service = s.patch(f"/api/v1/services/{service['id']}", json={"expose": True}) - service.raise_for_status() - service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] - - status = True - domain = "" - port = "" - connect_info = format_connection_info( - protocol=protocol, - hostname=service["hostname"], - tcp_hostname=service["tcp_hostname"], - tcp_port=service["tcp_port"], - ) - return status, domain, port, connect_info - - -DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry, "cloud": cloud} diff --git a/ctfcli/utils/git.py b/ctfcli/utils/git.py index 29a438d..a2ab596 100644 --- a/ctfcli/utils/git.py +++ b/ctfcli/utils/git.py @@ -1,37 +1,49 @@ import subprocess +from os import PathLike +from typing import Optional, Union -def get_git_repo_head_branch(repo): +def get_git_repo_head_branch(repo: str) -> Optional[str]: """ A helper method to get the reference of the HEAD branch of a git remote repo. https://stackoverflow.com/a/41925348 """ - out = subprocess.check_output( - ["git", "ls-remote", "--symref", repo, "HEAD"] - ).decode() - head_branch = out.split()[1] - if head_branch.startswith("refs/heads/"): - head_branch = head_branch[11:] - return head_branch + try: + output = subprocess.check_output(["git", "ls-remote", "--symref", repo, "HEAD"], stderr=subprocess.DEVNULL) + + # if for some reason subprocess didn't error, but returned None or an empty byte-string - return None + # this can happen if a repository exists, but doesn't have a head branch + if type(output) != bytes or len(output) == 0: + return None + + except subprocess.CalledProcessError: + return None + # otherwise process the output + output = output.decode().strip() + head_branch_line = output.split()[1] + if head_branch_line.startswith("refs/heads/"): + return head_branch_line[11:] -def check_if_dir_is_inside_git_repo(dir=None): + +def check_if_dir_is_inside_git_repo(cwd: Optional[Union[str, PathLike]] = None) -> bool: """ - Checks whether a given directory is inside of a git repo. + Checks whether a given directory is inside a git repo. """ try: out = ( subprocess.check_output( ["git", "rev-parse", "--is-inside-work-tree"], - cwd=dir, + cwd=cwd, stderr=subprocess.DEVNULL, ) .decode() .strip() ) - print(out) + if out == "true": return True + return False except subprocess.CalledProcessError: return False diff --git a/ctfcli/utils/images.py b/ctfcli/utils/images.py deleted file mode 100644 index c06649c..0000000 --- a/ctfcli/utils/images.py +++ /dev/null @@ -1,51 +0,0 @@ -import json -import subprocess -import tempfile -from pathlib import Path -from slugify import slugify - - -def login_registry(host, username, password): - subprocess.call(["docker", "login", "-u", username, "-p"], password, host) - - -def build_image(challenge): - name = slugify(challenge["name"]) - path = Path(challenge.file_path).parent.absolute() / challenge["image"] - print(f"Building {name} from {path}") - subprocess.call(["docker", "build", "-t", name, "."], cwd=path) - print(f"Built {name}") - return name - - -def push_image(local_tag, location): - print(f"Pushing {local_tag} to {location}") - subprocess.call(["docker", "tag", local_tag, location]) - subprocess.call(["docker", "push", location]) - - -def export_image(challenge): - name = slugify(challenge["name"]) - temp = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{name}.docker.tar") - subprocess.call(["docker", "save", "--output", temp.name, name]) - return temp.name - - -def get_exposed_ports(challenge): - image_name = slugify(challenge["name"]) - output = subprocess.check_output( - ["docker", "inspect", "--format={{json .Config.ExposedPorts }}", image_name,] - ) - output = json.loads(output) - if output: - ports = list(output.keys()) - if ports: - # Split '2323/tcp' - port = ports[0] - port = port.split("/") - port = port[0] - return port - else: - return None - else: - return None diff --git a/ctfcli/utils/pages.py b/ctfcli/utils/pages.py deleted file mode 100644 index a1a8d4f..0000000 --- a/ctfcli/utils/pages.py +++ /dev/null @@ -1,71 +0,0 @@ -from .config import generate_session - -FORMATS = { - ".md": "markdown", - ".html": "html", - ".htm": "html", -} - - -def get_current_pages(): - s = generate_session() - return s.get("/api/v1/pages", json=True).json()["data"] - - -def get_existing_page(route, pageset=None): - if pageset is None: - pageset = get_current_pages() - for page in pageset: - if route == page["route"]: - return page - return None - - -def get_format(ext): - return FORMATS[ext] - - -def sync_page(matter, path_obj, page_id): - route = matter["route"] - title = matter["title"] - content = matter.content - draft = bool(matter.get("draft")) - hidden = bool(matter.get("hidden")) - auth_required = bool(matter.get("auth_required")) - format = get_format(path_obj.suffix) - - s = generate_session() - data = { - "route": route, - "title": title, - "content": content, - "draft": draft, - "hidden": hidden, - "auth_required": auth_required, - "format": format, - } - r = s.patch(f"/api/v1/pages/{page_id}", json=data) - r.raise_for_status() - - -def install_page(matter, path_obj): - route = matter["route"] - title = matter["title"] - content = matter.content - draft = bool(matter.get("draft")) - hidden = bool(matter.get("hidden")) - auth_required = bool(matter.get("auth_required")) - format = get_format(path_obj.suffix) - - s = generate_session() - data = { - "route": route, - "title": title, - "content": content, - "draft": draft, - "hidden": hidden, - "auth_required": auth_required, - "format": format, - } - r = s.post("/api/v1/pages", json=data) - r.raise_for_status() diff --git a/ctfcli/utils/plugins.py b/ctfcli/utils/plugins.py deleted file mode 100644 index 4098348..0000000 --- a/ctfcli/utils/plugins.py +++ /dev/null @@ -1,49 +0,0 @@ -import appdirs -import importlib -import logging -import os -import sys -from pathlib import Path - -from ctfcli import __name__ as pkg_name - - -def load_plugins(commands: dict): - plugin_dir = get_plugin_dir() - sys.path.insert(0, plugin_dir) - - for plugin in sorted(os.listdir(plugin_dir)): - plugin_path = Path(plugin_dir) / plugin / "__init__.py" - - logging.debug(f"Loading {plugin_path} as {plugin}") - - loaded = importlib.import_module(plugin) - loaded.load(commands) - - sys.path.remove(str(plugin_dir)) - - -def get_plugin_dir(): - if os.getenv("CTFCLI_PLUGIN_DIR"): - plugins_path = get_custom_plugin_dir() - else: - plugins_path = get_data_dir() / "plugins" - - if not plugins_path.exists(): - os.makedirs(plugins_path) - - return str(plugins_path.absolute()) - - -def get_custom_plugin_dir() -> Path: - custom_plugins_path = Path(os.getenv("CTFCLI_PLUGIN_DIR")) - - if custom_plugins_path.is_absolute(): - return custom_plugins_path - - base_dir = Path().parent.parent - return base_dir / custom_plugins_path - - -def get_data_dir(): - return Path(appdirs.user_data_dir(appname=pkg_name)) diff --git a/ctfcli/utils/spec.py b/ctfcli/utils/spec.py deleted file mode 100644 index de5828a..0000000 --- a/ctfcli/utils/spec.py +++ /dev/null @@ -1,122 +0,0 @@ -import getpass -from collections import namedtuple -from pathlib import Path - -import yaml - -Prompt = namedtuple("Prompt", ["text", "type", "default", "required", "multiple"]) - - -CHALLENGE_SPEC_DOCS = { - "name": Prompt( - text="Challenge name or identifier", - type=None, - default=None, - required=True, - multiple=False, - ), - "author": Prompt( - text="Your name or handle", - type=None, - default=getpass.getuser(), - required=True, - multiple=False, - ), - "category": Prompt( - text="Challenge category", - type=None, - default=None, - required=True, - multiple=False, - ), - "description": Prompt( - text="Challenge description shown to the user", - type=None, - default=None, - required=True, - multiple=False, - ), - "value": Prompt( - text="How many points your challenge should be worth", - type=int, - default=None, - required=True, - multiple=False, - ), - "version": Prompt( - text="What version of the challenge specification was used", - type=None, - default="0.1", - required=False, - multiple=False, - ), - "image": Prompt( - text="Docker image used to deploy challenge", - type=None, - default=None, - required=False, - multiple=False, - ), - "type": Prompt( - text="Type of challenge", - type=None, - default="standard", - required=True, - multiple=False, - ), - "attempts": Prompt( - text="How many attempts should the player have", - type=int, - default=None, - required=False, - multiple=False, - ), - "flags": Prompt( - text="Flags that mark the challenge as solved", - type=None, - default=None, - required=False, - multiple=True, - ), - "tags": Prompt( - text="Tag that denotes a challenge topic", - type=None, - default=None, - required=False, - multiple=True, - ), - "files": Prompt( - text="Files to be shared with the user", - type=None, - default=None, - required=False, - multiple=True, - ), - "hints": Prompt( - text="Hints to be shared with the user", - type=None, - default=None, - required=False, - multiple=True, - ), - "requirements": Prompt( - text="Challenge dependencies that must be solved before this one can be attempted", - type=None, - default=None, - required=False, - multiple=True, - ), -} - - -def blank_challenge_spec(): - pwd = Path(__file__) - spec = pwd.parent.parent / "spec" / "challenge-example.yml" - with open(spec) as f: - blank = yaml.safe_load(f) - - for k in blank: - if k != "version": - blank[k] = None - - return blank diff --git a/ctfcli/utils/templates.py b/ctfcli/utils/templates.py deleted file mode 100644 index 394693b..0000000 --- a/ctfcli/utils/templates.py +++ /dev/null @@ -1,16 +0,0 @@ -import os - -import appdirs - -from ctfcli import __name__ as pkg_name - - -def get_template_dir(): - plugins_path = os.path.join(get_data_dir(), "templates") - if not os.path.exists(plugins_path): - os.makedirs(plugins_path) - return os.path.join(plugins_path) - - -def get_data_dir(): - return appdirs.user_data_dir(appname=pkg_name) diff --git a/ctfcli/utils/tools.py b/ctfcli/utils/tools.py index 1452f4e..bfe6957 100644 --- a/ctfcli/utils/tools.py +++ b/ctfcli/utils/tools.py @@ -1,19 +1,23 @@ import string -def strings(filename, min=4): +def strings(filename, min_length=4): """ Python implementation of strings https://stackoverflow.com/a/17197027 """ with open(filename, errors="ignore") as f: result = "" + for c in f.read(): if c in string.printable: result += c continue - if len(result) >= min: + + if len(result) >= min_length: yield result + result = "" - if len(result) >= min: # catch result at EOF + + if len(result) >= min_length: # catch result at EOF yield result diff --git a/development.txt b/development.txt deleted file mode 100644 index 2a2c786..0000000 --- a/development.txt +++ /dev/null @@ -1,7 +0,0 @@ --r requirements.txt -twine==3.1.1 -setuptools==46.1.3 -wheel==0.34.2 -black==19.10b0 -isort==4.3.21 -ruff==0.0.254 diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..c26e521 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,877 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = "*" +files = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] + +[[package]] +name = "arrow" +version = "1.2.3" +description = "Better dates & times for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "arrow-1.2.3-py3-none-any.whl", hash = "sha256:5a49ab92e3b7b71d96cd6bfcc4df14efefc9dfa96ea19045815914a6ab6b1fe2"}, + {file = "arrow-1.2.3.tar.gz", hash = "sha256:3934b30ca1b9f292376d9db15b19446088d12ec58629bc3f0da28fd55fb633a1"}, +] + +[package.dependencies] +python-dateutil = ">=2.7.0" + +[[package]] +name = "binaryornot" +version = "0.4.4" +description = "Ultra-lightweight pure Python package to check if a file is binary or text." +optional = false +python-versions = "*" +files = [ + {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"}, + {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"}, +] + +[package.dependencies] +chardet = ">=3.0.2" + +[[package]] +name = "black" +version = "23.7.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, + {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, + {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, + {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, + {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, + {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, + {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, + {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, + {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, + {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, + {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, + {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, + {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, +] + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.2.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, + {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cookiecutter" +version = "2.3.0" +description = "A command-line utility that creates projects from project templates, e.g. creating a Python package project from a Python package project template." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cookiecutter-2.3.0-py3-none-any.whl", hash = "sha256:7e87944757c6e9f8729cf89a4139b6a35ab4d6dcbc6ae3e7d6360d44ad3ad383"}, + {file = "cookiecutter-2.3.0.tar.gz", hash = "sha256:942a794981747f6d7f439d6e49d39dc91a9a641283614160c93c474c72c29621"}, +] + +[package.dependencies] +arrow = "*" +binaryornot = ">=0.4.4" +click = ">=7.0,<9.0.0" +Jinja2 = ">=2.7,<4.0.0" +python-slugify = ">=4.0.0" +pyyaml = ">=5.3.1" +requests = ">=2.23.0" +rich = "*" + +[[package]] +name = "coverage" +version = "7.3.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5"}, + {file = "coverage-7.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1"}, + {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977"}, + {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51"}, + {file = "coverage-7.3.0-cp310-cp310-win32.whl", hash = "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527"}, + {file = "coverage-7.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1"}, + {file = "coverage-7.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f"}, + {file = "coverage-7.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7"}, + {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214"}, + {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f"}, + {file = "coverage-7.3.0-cp311-cp311-win32.whl", hash = "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482"}, + {file = "coverage-7.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70"}, + {file = "coverage-7.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b"}, + {file = "coverage-7.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe"}, + {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2"}, + {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b"}, + {file = "coverage-7.3.0-cp312-cp312-win32.whl", hash = "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321"}, + {file = "coverage-7.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479"}, + {file = "coverage-7.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac0dec90e7de0087d3d95fa0533e1d2d722dcc008bc7b60e1143402a04c117c1"}, + {file = "coverage-7.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:438856d3f8f1e27f8e79b5410ae56650732a0dcfa94e756df88c7e2d24851fcd"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1084393c6bda8875c05e04fce5cfe1301a425f758eb012f010eab586f1f3905e"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49ab200acf891e3dde19e5aa4b0f35d12d8b4bd805dc0be8792270c71bd56c54"}, + {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67e6bbe756ed458646e1ef2b0778591ed4d1fcd4b146fc3ba2feb1a7afd4254"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f39c49faf5344af36042b293ce05c0d9004270d811c7080610b3e713251c9b0"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7df91fb24c2edaabec4e0eee512ff3bc6ec20eb8dccac2e77001c1fe516c0c84"}, + {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:34f9f0763d5fa3035a315b69b428fe9c34d4fc2f615262d6be3d3bf3882fb985"}, + {file = "coverage-7.3.0-cp38-cp38-win32.whl", hash = "sha256:bac329371d4c0d456e8d5f38a9b0816b446581b5f278474e416ea0c68c47dcd9"}, + {file = "coverage-7.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b859128a093f135b556b4765658d5d2e758e1fae3e7cc2f8c10f26fe7005e543"}, + {file = "coverage-7.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed8d310afe013db1eedd37176d0839dc66c96bcfcce8f6607a73ffea2d6ba"}, + {file = "coverage-7.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61260ec93f99f2c2d93d264b564ba912bec502f679793c56f678ba5251f0393"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97af9554a799bd7c58c0179cc8dbf14aa7ab50e1fd5fa73f90b9b7215874ba28"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3558e5b574d62f9c46b76120a5c7c16c4612dc2644c3d48a9f4064a705eaee95"}, + {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37d5576d35fcb765fca05654f66aa71e2808d4237d026e64ac8b397ffa66a56a"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:07ea61bcb179f8f05ffd804d2732b09d23a1238642bf7e51dad62082b5019b34"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:80501d1b2270d7e8daf1b64b895745c3e234289e00d5f0e30923e706f110334e"}, + {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4eddd3153d02204f22aef0825409091a91bf2a20bce06fe0f638f5c19a85de54"}, + {file = "coverage-7.3.0-cp39-cp39-win32.whl", hash = "sha256:2d22172f938455c156e9af2612650f26cceea47dc86ca048fa4e0b2d21646ad3"}, + {file = "coverage-7.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:60f64e2007c9144375dd0f480a54d6070f00bb1a28f65c408370544091c9bc9e"}, + {file = "coverage-7.3.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0"}, + {file = "coverage-7.3.0.tar.gz", hash = "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fire" +version = "0.5.0" +description = "A library for automatically generating command line interfaces." +optional = false +python-versions = "*" +files = [ + {file = "fire-0.5.0.tar.gz", hash = "sha256:a6b0d49e98c8963910021f92bba66f65ab440da2982b78eb1bbf95a0a34aacc6"}, +] + +[package.dependencies] +six = "*" +termcolor = "*" + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isort" +version = "5.12.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, + {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, +] + +[package.extras] +colors = ["colorama (>=0.4.3)"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "23.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, +] + +[[package]] +name = "pathspec" +version = "0.11.2" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, +] + +[[package]] +name = "platformdirs" +version = "3.10.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, + {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pluggy" +version = "1.2.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pytest" +version = "7.4.0" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-sugar" +version = "0.9.7" +description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." +optional = false +python-versions = "*" +files = [ + {file = "pytest-sugar-0.9.7.tar.gz", hash = "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46"}, + {file = "pytest_sugar-0.9.7-py2.py3-none-any.whl", hash = "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062"}, +] + +[package.dependencies] +packaging = ">=21.3" +pytest = ">=6.2.0" +termcolor = ">=2.1.0" + +[package.extras] +dev = ["black", "flake8", "pre-commit"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-frontmatter" +version = "1.0.0" +description = "Parse and manage posts with YAML (or other) frontmatter" +optional = false +python-versions = "*" +files = [ + {file = "python-frontmatter-1.0.0.tar.gz", hash = "sha256:e98152e977225ddafea6f01f40b4b0f1de175766322004c826ca99842d19a7cd"}, + {file = "python_frontmatter-1.0.0-py3-none-any.whl", hash = "sha256:766ae75f1b301ffc5fe3494339147e0fd80bc3deff3d7590a93991978b579b08"}, +] + +[package.dependencies] +PyYAML = "*" + +[package.extras] +docs = ["sphinx"] +test = ["pyaml", "pytest", "toml"] + +[[package]] +name = "python-slugify" +version = "8.0.1" +description = "A Python slugify application that also handles Unicode" +optional = false +python-versions = ">=3.7" +files = [ + {file = "python-slugify-8.0.1.tar.gz", hash = "sha256:ce0d46ddb668b3be82f4ed5e503dbc33dd815d83e2eb6824211310d3fb172a27"}, + {file = "python_slugify-8.0.1-py2.py3-none-any.whl", hash = "sha256:70ca6ea68fe63ecc8fa4fcf00ae651fc8a5d02d93dcd12ae6d4fc7ca46c4d395"}, +] + +[package.dependencies] +text-unidecode = ">=1.3" + +[package.extras] +unidecode = ["Unidecode (>=1.1.1)"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "13.5.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.5.2-py3-none-any.whl", hash = "sha256:146a90b3b6b47cac4a73c12866a499e9817426423f57c5a66949c086191a8808"}, + {file = "rich-13.5.2.tar.gz", hash = "sha256:fb9d6c0a0f643c99eed3875b5377a184132ba9be4d61516a55273d3554d75a39"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "ruff" +version = "0.0.284" +description = "An extremely fast Python linter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.0.284-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:8b949084941232e2c27f8d12c78c5a6a010927d712ecff17231ee1a8371c205b"}, + {file = "ruff-0.0.284-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:a3930d66b35e4dc96197422381dff2a4e965e9278b5533e71ae8474ef202fab0"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d1f7096038961d8bc3b956ee69d73826843eb5b39a5fa4ee717ed473ed69c95"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcaf85907fc905d838f46490ee15f04031927bbea44c478394b0bfdeadc27362"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3660b85a9d84162a055f1add334623ae2d8022a84dcd605d61c30a57b436c32"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0a3218458b140ea794da72b20ea09cbe13c4c1cdb7ac35e797370354628f4c05"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2fe880cff13fffd735387efbcad54ba0ff1272bceea07f86852a33ca71276f4"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1d098ea74d0ce31478765d1f8b4fbdbba2efc532397b5c5e8e5ea0c13d7e5ae"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c79ae3308e308b94635cd57a369d1e6f146d85019da2fbc63f55da183ee29b"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f86b2b1e7033c00de45cc176cf26778650fb8804073a0495aca2f674797becbb"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e37e086f4d623c05cd45a6fe5006e77a2b37d57773aad96b7802a6b8ecf9c910"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d29dfbe314e1131aa53df213fdfea7ee874dd96ea0dd1471093d93b59498384d"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:88295fd649d0aa1f1271441df75bf06266a199497afd239fd392abcfd75acd7e"}, + {file = "ruff-0.0.284-py3-none-win32.whl", hash = "sha256:735cd62fccc577032a367c31f6a9de7c1eb4c01fa9a2e60775067f44f3fc3091"}, + {file = "ruff-0.0.284-py3-none-win_amd64.whl", hash = "sha256:f67ed868d79fbcc61ad0fa034fe6eed2e8d438d32abce9c04b7c4c1464b2cf8e"}, + {file = "ruff-0.0.284-py3-none-win_arm64.whl", hash = "sha256:1292cfc764eeec3cde35b3a31eae3f661d86418b5e220f5d5dba1c27a6eccbb6"}, + {file = "ruff-0.0.284.tar.gz", hash = "sha256:ebd3cc55cd499d326aac17a331deaea29bea206e01c08862f9b5c6e93d77a491"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "termcolor" +version = "2.3.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.7" +files = [ + {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, + {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +optional = false +python-versions = "*" +files = [ + {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, + {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.7.1" +description = "Backported and Experimental Type Hints for Python 3.7+" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, +] + +[[package]] +name = "urllib3" +version = "2.0.4" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.7" +files = [ + {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, + {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "wheel" +version = "0.41.2" +description = "A built-package format for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "wheel-0.41.2-py3-none-any.whl", hash = "sha256:75909db2664838d015e3d9139004ee16711748a52c8f336b52882266540215d8"}, + {file = "wheel-0.41.2.tar.gz", hash = "sha256:0c5ac5ff2afb79ac23ab82bab027a0be7b5dbcf2e54dc50efe4bf507de1f7985"}, +] + +[package.extras] +test = ["pytest (>=6.0.0)", "setuptools (>=65)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "0ef27b92e77eed597413ec6906f167d178ae6d2455d84b8e26bb1413e54e5315" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1f587ce --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[tool.poetry] +name = "ctfcli" +version = "0.1.0" +description = "ctfcli is a tool to manage Capture The Flag events and challenges" +authors = ["Kevin Chung ", "MiƂosz Skaza "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.8" +python-frontmatter = "^1.0.0" +requests = "^2.31.0" +pyyaml = "^6.0.1" +click = "^8.1.7" +cookiecutter = "^2.3.0" +appdirs = "^1.4.4" +colorama = "^0.4.6" +fire = "^0.5.0" +typing-extensions = "^4.7.1" + +[tool.poetry.group.dev.dependencies] +black = "^23.7.0" +ruff = "^0.0.284" +isort = "^5.12.0" +pytest = "^7.4.0" +pytest-sugar = "^0.9.7" +pytest-cov = "^4.1.0" +wheel = "^0.41.2" + +[tool.ruff] +line-length = 120 +ignore = ["E721"] +exclude = ["build", "ctfcli/templates"] + +[tool.isort] +profile = "black" + +[tool.black] +line-length = 120 +target-version = ['py311'] +extend-exclude = ''' +( + /build + | /ctfcli/templates + | /venv +) +''' + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b472c95..0000000 --- a/requirements.txt +++ /dev/null @@ -1,65 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.9 -# by the following command: -# -# ./scripts/pip-compile.sh -# -appdirs==1.4.3 - # via -r requirements.in -arrow==1.2.3 - # via jinja2-time -binaryornot==0.4.4 - # via cookiecutter -certifi==2022.12.7 - # via requests -chardet==5.0.0 - # via binaryornot -charset-normalizer==2.1.1 - # via requests -click==7.0 - # via - # -r requirements.in - # cookiecutter -colorama==0.4.6 - # via -r requirements.in -cookiecutter==2.1.1 - # via -r requirements.in -fire==0.2.1 - # via -r requirements.in -idna==3.4 - # via requests -jinja2==3.1.2 - # via - # cookiecutter - # jinja2-time -jinja2-time==0.2.0 - # via cookiecutter -markupsafe==2.1.1 - # via jinja2 -pygments==2.7.4 - # via -r requirements.in -python-dateutil==2.8.2 - # via arrow -python-frontmatter==1.0.0 - # via -r requirements.in -python-slugify==6.1.2 - # via cookiecutter -pyyaml==6.0.1 - # via - # -r requirements.in - # cookiecutter - # python-frontmatter -requests==2.28.1 - # via - # -r requirements.in - # cookiecutter -six==1.16.0 - # via - # fire - # python-dateutil -termcolor==2.1.0 - # via fire -text-unidecode==1.3 - # via python-slugify -urllib3==1.26.12 - # via requests diff --git a/scripts/pip-compile.sh b/scripts/pip-compile.sh deleted file mode 100755 index 21f404b..0000000 --- a/scripts/pip-compile.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -# Script to pin Python requirements in a Docker container -ROOTDIR=`pwd -P` -docker run \ - --rm \ - --entrypoint bash \ - -v $ROOTDIR:/mnt/ctfcli \ - -e CUSTOM_COMPILE_COMMAND='./scripts/pip-compile.sh' \ - -it python:3.9-slim-buster \ - -c 'cd /mnt/ctfcli && pip install pip-tools==6.14.0 && pip-compile' \ No newline at end of file diff --git a/setup.py b/setup.py index e0bf9fa..5bd6f9d 100644 --- a/setup.py +++ b/setup.py @@ -3,9 +3,9 @@ import re try: - from setuptools import setup, find_packages + from setuptools import find_packages, setup except ImportError: - from distutils.core import setup, find_packages + from distutils.core import find_packages, setup with open("ctfcli/__init__.py") as f: @@ -33,17 +33,17 @@ def read(fname): classifiers=[], zip_safe=False, install_requires=[ - "cookiecutter==2.1.1", - "click==7.0", - "fire==0.2.1", - "pyyaml>=6.0.1", - "Pygments==2.7.4", - "requests==2.28.1", + "cookiecutter==2.3.0", + "click==8.1.6", + "fire==0.5.0", + "pyyaml==6.0.1", + "Pygments==2.16.1", + "requests==2.31.0", "colorama==0.4.6", - "appdirs==1.4.3", + "appdirs==1.4.4", "python-frontmatter==1.0.0", ], - packages=find_packages(), + packages=find_packages(exclude=["tests", "tests.*"]), include_package_data=True, entry_points={"console_scripts": ["ctf = ctfcli.__main__:main"]}, ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py new file mode 100644 index 0000000..e30dba9 --- /dev/null +++ b/tests/cli/test_main.py @@ -0,0 +1,71 @@ +import contextlib +import io +import unittest + +import fire + +from ctfcli import __main__ as ctfcli + + +class TestCLIEntrypoint(unittest.TestCase): + def test_loads_challenge_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["challenge"]) + + # fmt: off + expected_commands = [ + "add", "deploy", "edit", "healthcheck", + "install", "lint", "new", "pull", "push", + "restore", "show", "sync", "templates", + "view", + ] + # fmt: on + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) + + def test_loads_config_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["config"]) + + expected_commands = ["edit", "path", "show", "view"] + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) + + def test_loads_pages_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["pages"]) + + expected_commands = ["pull", "push", "sync"] + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) + + def test_loads_plugins_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["plugins"]) + + expected_commands = ["dir", "install", "list", "path", "uninstall"] + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) + + def test_loads_templates_command(self): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + fire.Fire(ctfcli.CTFCLI, ["templates"]) + + expected_commands = ["dir", "install", "list", "path", "uninstall"] + + stdout = stdout.getvalue() + for command in expected_commands: + self.assertIn(command, stdout) diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/deployment/__init__.py b/tests/core/deployment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/deployment/test_base_deployment_handler.py b/tests/core/deployment/test_base_deployment_handler.py new file mode 100644 index 0000000..9d8a546 --- /dev/null +++ b/tests/core/deployment/test_base_deployment_handler.py @@ -0,0 +1,57 @@ +import unittest +from pathlib import Path + +from ctfcli.core.challenge import Challenge +from ctfcli.core.deployment import ( + DEPLOYMENT_HANDLERS, + CloudDeploymentHandler, + DeploymentHandler, + RegistryDeploymentHandler, + SSHDeploymentHandler, + get_deployment_handler, + register_deployment_handler, +) +from ctfcli.core.deployment.base import DeploymentResult + +BASE_DIR = Path(__file__).parent.parent.parent + + +class TestDeploymentHandlerLoading(unittest.TestCase): + def test_get_deployment_handler(self): + handlers = { + "cloud": CloudDeploymentHandler, + "ssh": SSHDeploymentHandler, + "registry": RegistryDeploymentHandler, + } + + for key, handler in handlers.items(): + handler_class = get_deployment_handler(key) + self.assertIs(handlers[key], handler_class) + + def test_register_deployment_handler(self): + class MyDeploymentHandler(DeploymentHandler): + def deploy(self, *args, **kwargs) -> DeploymentResult: + return DeploymentResult(False) + + register_deployment_handler("my-handler", MyDeploymentHandler) + self.assertIs(DEPLOYMENT_HANDLERS["my-handler"], MyDeploymentHandler) + + +class TestBaseDeploymentHandler(unittest.TestCase): + challenge_directory = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + challenge_path = challenge_directory / "challenge.yml" + + def test_assigns_attributes(self): + challenge = Challenge(self.challenge_path) + handler = DeploymentHandler(challenge, "example.com", "https") + + self.assertIs(challenge, handler.challenge) + self.assertEqual("example.com", handler.host) + self.assertEqual("https", handler.protocol) + + def test_does_not_implement_deploy(self): + challenge = Challenge(self.challenge_path) + handler = DeploymentHandler(challenge, "example.com", "https") + + with self.assertRaises(NotImplementedError): + handler.deploy() diff --git a/tests/core/deployment/test_cloud_deployment.py b/tests/core/deployment/test_cloud_deployment.py new file mode 100644 index 0000000..7221765 --- /dev/null +++ b/tests/core/deployment/test_cloud_deployment.py @@ -0,0 +1,1200 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.challenge import Challenge +from ctfcli.core.deployment import CloudDeploymentHandler +from ctfcli.core.deployment.base import DeploymentResult + +BASE_DIR = Path(__file__).parent.parent.parent + + +class TestCloudDeployment(unittest.TestCase): + challenge_directory = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + challenge_path = challenge_directory / "challenge.yml" + + mock_user_response = { + "success": True, + "data": { + "affiliation": None, + "oauth_id": None, + "fields": [], + "name": "admin", + "language": None, + "bracket": None, + "email": "test@example.com", + "id": 1, + "website": None, + "team_id": 1, + "place": None, + "score": 0, + }, + } + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.API") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + def test_fails_deployment_if_challenge_does_not_provide_image(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path, {"image": None}) + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Challenge does not define an image to deploy", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.API") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + def test_fails_deployment_if_instance_does_not_support_deployments( + self, mock_secho: MagicMock, mock_api_constructor: MagicMock, *args, **kwargs + ): + mock_response = MagicMock() + mock_response.ok = False + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value = mock_response + + challenge = Challenge(self.challenge_path) + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_api.get.assert_called_once_with("/api/v1/images") + mock_secho.assert_called_once_with("Target instance does not support cloud deployments", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_if_image_build_failed( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = None + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + def mock_get(*args, **kwargs): + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + mock_api.get.side_effect = mock_get + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + ] + ) + + mock_api.patch.assert_not_called() + + # expect an error message + mock_secho.assert_called_once_with("Could not build the image. Please check docker output above.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_if_image_push_failed( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = None + + mock_api: MagicMock = mock_api_constructor.return_value + + def mock_get(*args, **kwargs): + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + mock_api.get.side_effect = mock_get + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy(skip_login=True) + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + ] + ) + + mock_api.patch.assert_not_called() + + # expect an error message + mock_secho.assert_has_calls( + [ + call( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ), + call("Could not push image to the registry.", fg="red"), + call( + "Remember that you need to manually login to the docker registry when using --skip-login", + fg="yellow", + ), + ] + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch( + "ctfcli.core.deployment.cloud.subprocess.check_output", + return_value=b'Error response from daemon: Get "https://registry.ctfd.io/v2/": unauthorized: authentication required', # noqa + ) + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_if_registry_login_unsuccessful( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + mock_api.get.side_effect = mock_get + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/users/me"), # get username for registry login + ] + ) + + mock_api.patch.assert_not_called() + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + # expect an error message + mock_secho.assert_called_once_with( + "Could not log in to the registry. Please check your access token and instance URL", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_if_instance_url_is_not_ctfd_assigned( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://custom-project.example.com/" + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [], + } + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + mock_api.get.side_effect = mock_get + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/users/me"), # get username for registry login + ] + ) + + mock_api.patch.assert_not_called() + + # check docker registry login didn't happen + mock_subprocess.assert_not_called() + + # expect an error message + mock_secho.assert_has_calls( + [ + call( + "Instance URL is not a CTFd assigned URL. Either use the CTFd assigned domain name, " + "or login to the registry manually and deploy with --skip-login", + fg="red", + ), + call("Could not log in to the registry. Please check your access token and instance URL", fg="red"), + ] + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_allows_skipping_registry_login( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "pushed", + "location": "registry.ctfd.io/example-project/test-challenge", + } + ], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [{"id": 1, "name": "test-challenge", "status": "deployed"}], + } + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": "registry.ctfd.io/example-project/test-challenge:latest", + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + } + return mock_response + + def mock_patch(*args, **kwargs): + new_image = kwargs.get("json", {}).get("image", None) + + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": new_image, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + } + ], + } + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.patch.side_effect = mock_patch + + handler = CloudDeploymentHandler(challenge, protocol="https") + result = handler.deploy(skip_login=True) + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("https://example-project-test-challenge.chals.io", result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status + ] + ) + + mock_api.patch.assert_has_calls( + [ + # update service image + call("/api/v1/services/1", json={"image": "registry.ctfd.io/example-project/test-challenge"}) + ] + ) + + # check docker registry login didn't happen + mock_subprocess.assert_not_called() + + # expect a warning for skip_login + mock_secho.assert_called_once_with( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output", return_value=b"Login Succeeded") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_deploys_challenge_with_existing_image_service( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "pushed", + "location": "registry.ctfd.io/example-project/test-challenge", + } + ], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [{"id": 1, "name": "test-challenge", "status": "deployed"}], + } + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": "registry.ctfd.io/example-project/test-challenge:latest", + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + } + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + def mock_patch(*args, **kwargs): + new_image = kwargs.get("json", {}).get("image", None) + + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": new_image, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + } + ], + } + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.patch.side_effect = mock_patch + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("https://example-project-test-challenge.chals.io", result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/users/me"), # get username for registry login + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status + ] + ) + + mock_api.patch.assert_has_calls( + [ + # update service image + call("/api/v1/services/1", json={"image": "registry.ctfd.io/example-project/test-challenge"}) + ] + ) + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + # do not expect a call to secho as in this case the mocked deployment is instant + mock_secho.assert_not_called() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output", return_value=b"Login Succeeded") + @mock.patch("ctfcli.core.deployment.cloud.time.sleep") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_deploys_challenge_with_new_image_service( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_sleep: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + # return a deployed service response on the 3rd status check + service_status_responses = [ + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + }, + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + }, + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "image": "registry.ctfd.io/example-project/test-challenge", + "internal_port": 80, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + }, + ] + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = service_status_responses.pop(0) + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": None, + "location": "registry.ctfd.io/example-project/test-challenge", + }, + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + } + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("https://example-project-test-challenge.chals.io", result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get existing images + call("/api/v1/users/me"), # get user data for registry login + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status (1st) + call("/api/v1/services/1"), # await service deployment #1 (2nd) + call("/api/v1/services/1"), # await service deployment #2 (3rd) - should be deployed + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/images", json={"name": "test-challenge"}), + call( + "/api/v1/services", + json={"name": "test-challenge", "image": "registry.ctfd.io/example-project/test-challenge"}, + ), + ] + ) + + mock_secho.assert_has_calls( + [ + call("Awaiting service deployment [0/180s]", fg="yellow"), + call("Awaiting service deployment [10/180s]", fg="yellow"), + ] + ) + + mock_sleep.assert_has_calls( + [ + call(10), + call(10), + ] + ) + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output", return_value=b"Login Succeeded") + @mock.patch("ctfcli.core.deployment.cloud.time.sleep") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_fails_deployment_after_timeout( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_sleep: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + } + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": None, + "location": "registry.ctfd.io/example-project/test-challenge", + }, + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "built", + "scale": None, + "hostname": None, + "internal_port": None, + "image": None, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": None, + }, + } + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + self.assertIsNone(result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get existing images + call("/api/v1/users/me"), # get user data + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status + call("/api/v1/services/1"), # await service deployment #1 + call("/api/v1/services/1"), # await service deployment #2 + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/images", json={"name": "test-challenge"}), + call( + "/api/v1/services", + json={"name": "test-challenge", "image": "registry.ctfd.io/example-project/test-challenge"}, + ), + ] + ) + + mock_secho.assert_has_calls( + [ + call("Awaiting service deployment [0/180s]", fg="yellow"), + call("Awaiting service deployment [10/180s]", fg="yellow"), + call("Awaiting service deployment [20/180s]", fg="yellow"), + call("Awaiting service deployment [30/180s]", fg="yellow"), + call("Awaiting service deployment [40/180s]", fg="yellow"), + call("Awaiting service deployment [50/180s]", fg="yellow"), + call("Awaiting service deployment [60/180s]", fg="yellow"), + call("Awaiting service deployment [70/180s]", fg="yellow"), + call("Awaiting service deployment [80/180s]", fg="yellow"), + call("Awaiting service deployment [90/180s]", fg="yellow"), + call("Awaiting service deployment [100/180s]", fg="yellow"), + call("Awaiting service deployment [110/180s]", fg="yellow"), + call("Awaiting service deployment [120/180s]", fg="yellow"), + call("Awaiting service deployment [130/180s]", fg="yellow"), + call("Awaiting service deployment [140/180s]", fg="yellow"), + call("Awaiting service deployment [150/180s]", fg="yellow"), + call("Awaiting service deployment [160/180s]", fg="yellow"), + call("Awaiting service deployment [170/180s]", fg="yellow"), + call("Timeout awaiting challenge deployment", fg="red"), + ] + ) + + mock_sleep.assert_has_calls( + [ + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + call(10), + ] + ) + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.cloud.subprocess.check_output", return_value=b"Login Succeeded") + @mock.patch("ctfcli.core.deployment.cloud.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + @mock.patch("ctfcli.core.deployment.cloud.API") + def test_exposes_tcp_port( + self, + mock_api_constructor: MagicMock, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path, {"protocol": "tcp"}) + + mock_image = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.ctfd.io/example-project/test-challenge" + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.prefix_url = "https://example-project.ctfd.io/" + mock_api.access_token = "deadbeef" + + service_status_responses = [ + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": "registry.ctfd.io/example-project/test-challenge:latest", + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + }, + { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": "registry.ctfd.io/example-project/test-challenge:latest", + "tcp_hostname": "0.cloud.chals.io", + "tcp_port": "31900", + "memory_limit": "256 MB", + }, + }, + ] + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/images": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [ + { + "id": 1, + "name": "test-challenge", + "status": "pushed", + "location": "registry.ctfd.io/example-project/test-challenge", + } + ], + } + return mock_response + + if path == "/api/v1/services": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": [{"id": 1, "name": "test-challenge", "status": "deployed"}], + } + return mock_response + + if path == "/api/v1/services/1": + mock_response = MagicMock() + mock_response.ok = True + + mock_response.json.return_value = service_status_responses.pop(0) + return mock_response + + if path == "/api/v1/users/me": + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = self.mock_user_response + return mock_response + + def mock_patch(*args, **kwargs): + new_image = kwargs.get("json").get("image") + + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = { + "success": True, + "data": { + "id": 1, + "name": "test-challenge", + "status": "deployed", + "scale": 1, + "hostname": "example-project-test-challenge.chals.io", + "internal_port": "80", + "image": new_image, + "tcp_hostname": None, + "tcp_port": None, + "memory_limit": "256 MB", + }, + } + + return mock_response + + mock_api.get.side_effect = mock_get + mock_api.patch.side_effect = mock_patch + + handler = CloudDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("nc 0.cloud.chals.io 31900", result.connection_info) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/images"), # check if deployments are supported + call("/api/v1/images"), # get image information + call("/api/v1/users/me"), # get user data + call("/api/v1/services"), # get existing services + call("/api/v1/services/1"), # get service information & check deployment status + ] + ) + + mock_api.patch.assert_has_calls( + [ + # update service image + call("/api/v1/services/1", json={"image": "registry.ctfd.io/example-project/test-challenge"}), + # expose TCP port + call("/api/v1/services/1", json={"expose": True}), + ] + ) + + # do not expect a call to secho as in this case the mocked deployment is instant + mock_secho.assert_not_called() + + # check docker registry login + mock_subprocess.assert_called_once_with( + ["docker", "login", "-u", "admin@example-project.ctfd.io", "--password-stdin", "registry.ctfd.io"], + input=b"deadbeef", + stderr=-1, + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + handler = CloudDeploymentHandler(challenge, protocol="http") + + self.assertEqual( + "https://example-project-test-challenge.ctfd.io", + handler._get_connection_info("example-project-test-challenge.ctfd.io"), + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_tcp(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + handler = CloudDeploymentHandler(challenge, protocol="tcp") + + self.assertEqual( + "nc 0.cloud.chals.io 30054", + handler._get_connection_info( + "example-project-test-challenge.ctfd.io", tcp_hostname="0.cloud.chals.io", tcp_port="30054" + ), + ) + + @mock.patch( + "ctfcli.core.config.Path.cwd", return_value=BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + ) + def test_get_connection_info_fallback(self, *args, **kwargs): + # test with a challenge that does not define a protocol + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + + challenge = Challenge(minimal_challenge) + handler = CloudDeploymentHandler(challenge) + + self.assertEqual( + "example-project-test-challenge.ctfd.io", + handler._get_connection_info("example-project-test-challenge.ctfd.io"), + ) diff --git a/tests/core/deployment/test_registry_deployment.py b/tests/core/deployment/test_registry_deployment.py new file mode 100644 index 0000000..ebb3ee8 --- /dev/null +++ b/tests/core/deployment/test_registry_deployment.py @@ -0,0 +1,282 @@ +import unittest +from pathlib import Path +from subprocess import CalledProcessError +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.challenge import Challenge +from ctfcli.core.deployment import RegistryDeploymentHandler +from ctfcli.core.deployment.base import DeploymentResult + +BASE_DIR = Path(__file__).parent.parent.parent + + +class TestRegistryDeployment(unittest.TestCase): + challenge_directory = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + challenge_path = challenge_directory / "challenge.yml" + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_builds_and_pushes_image( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.example.com/example-project/test-challenge" + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + def test_fails_deployment_if_challenge_does_not_provide_image(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path, {"image": None, "host": "registry.example.com/example-project"}) + handler = RegistryDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Challenge does not define an image to deploy", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + def test_fails_deployment_if_no_host_provided(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path) + handler = RegistryDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "No host provided for the deployment. Use --host, or define host in the challenge.yml file", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_no_registry_config( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Config does not provide a registry section.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_if_no_credentials( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + + mock_config_constructor.return_value = {"registry": {"username": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Config is missing credentials for the registry.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_if_registry_credentials_invalid( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_subprocess: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + mock_subprocess.side_effect = [CalledProcessError(1, "")] + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "Could not log in to the registry. Please check your configured credentials.", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_image_build_failed( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = None + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Could not build the image. Please check docker output above.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_image_push_failed( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = None + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "Could not push image to the registry. Please check docker output above.", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.deployment.registry.Config") + @mock.patch("ctfcli.core.challenge.Image") + def test_allows_skipping_login( + self, + mock_image_constructor: MagicMock, + mock_config_constructor: MagicMock, + mock_subprocess: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = "registry.example.com/example-project/test-challenge" + + mock_config_constructor.return_value = {"registry": {"username": "test", "password": "test"}} + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy(skip_login=True) + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + + mock_secho.assert_called_once_with( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ) + mock_subprocess.assert_not_called() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.registry.click.secho") + @mock.patch("ctfcli.core.deployment.registry.subprocess.run") + @mock.patch("ctfcli.core.challenge.Image") + def test_warns_about_logging_in_with_skip_login( + self, + mock_image_constructor: MagicMock, + mock_subprocess: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.push.return_value = None + + mock_subprocess.side_effect = [CalledProcessError(1, "")] + + handler = RegistryDeploymentHandler(challenge, host="registry://registry.example.com/example-project") + result = handler.deploy(skip_login=True) + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_has_calls( + [ + call( + "Skipping registry login because of --skip-login. Make sure you are logged in to the registry.", + fg="yellow", + ), + call("Could not push image to the registry. Please check docker output above.", fg="red"), + call( + "Remember that you need to manually login to the docker registry when using --skip-login", + fg="yellow", + ), + ] + ) diff --git a/tests/core/deployment/test_ssh_deployment.py b/tests/core/deployment/test_ssh_deployment.py new file mode 100644 index 0000000..caeb700 --- /dev/null +++ b/tests/core/deployment/test_ssh_deployment.py @@ -0,0 +1,240 @@ +import unittest +from pathlib import Path +from subprocess import CalledProcessError +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.challenge import Challenge +from ctfcli.core.deployment import SSHDeploymentHandler +from ctfcli.core.deployment.base import DeploymentResult + +BASE_DIR = Path(__file__).parent.parent.parent + + +class TestSSHDeployment(unittest.TestCase): + challenge_directory = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + challenge_path = challenge_directory / "challenge.yml" + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch.object(Path, "unlink") + @mock.patch("ctfcli.core.deployment.ssh.subprocess.run") + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_builds_exports_and_copies_image( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + mock_unlink: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.export.return_value = "/tmp/test/test-challenge.tar" + mock_image.get_exposed_port.return_value = 80 + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertTrue(result.success) + self.assertEqual("http://127.0.0.1", result.connection_info) + + mock_secho.assert_not_called() + mock_subprocess.assert_has_calls( + [ + call(["scp", Path("/tmp/test/test-challenge.tar"), "root@127.0.0.1:/tmp/test-challenge.tar"]), + call(["ssh", "root@127.0.0.1", "docker load -i /tmp/test-challenge.tar && rm /tmp/test-challenge.tar"]), + call( + [ + "ssh", + "root@127.0.0.1", + "docker stop test-challenge 2>/dev/null; docker rm test-challenge 2>/dev/null", + ] + ), + call( + [ + "ssh", + "root@127.0.0.1", + "docker run -d -p80:80 --name test-challenge --restart always test-challenge", + ] + ), + ] + ) + + mock_unlink.assert_called_once() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + def test_fails_deployment_if_challenge_does_not_provide_image(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path, {"image": None, "host": "ssh://root@127.0.0.1"}) + handler = SSHDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Challenge does not define an image to deploy", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + def test_fails_deployment_if_no_host_provided(self, mock_secho: MagicMock, *args, **kwargs): + challenge = Challenge(self.challenge_path) + handler = SSHDeploymentHandler(challenge) + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "No host provided for the deployment. Use --host, or define host in the challenge.yml file", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_image_build_failed( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = None + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Could not build the image. Please check docker output above.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_image_export_failed( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.export.return_value = None + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with("Could not export the image. Please check docker output above.", fg="red") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_no_exposed_port( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.export.return_value = "/tmp/test/test-challenge.tar" + mock_image.get_exposed_port.return_value = None + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_called_once_with( + "Could not resolve a port to expose. Make sure your Dockerfile EXPOSE's a port.", fg="red" + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + @mock.patch("ctfcli.core.deployment.ssh.subprocess.run") + @mock.patch("ctfcli.core.deployment.ssh.click.secho") + @mock.patch("ctfcli.core.challenge.Image") + def test_fails_deployment_if_any_subprocess_exits( + self, + mock_image_constructor: MagicMock, + mock_secho: MagicMock, + mock_subprocess: MagicMock, + *args, + **kwargs, + ): + challenge = Challenge(self.challenge_path) + + mock_image: MagicMock = mock_image_constructor.return_value + mock_image.name = "test-challenge" + mock_image.build.return_value = "test-challenge" + mock_image.export.return_value = "/tmp/test/test-challenge.tar" + mock_image.get_exposed_port.return_value = 80 + + mock_subprocess.side_effect = [CalledProcessError(1, "test-exception")] + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + result = handler.deploy() + + self.assertIsInstance(result, DeploymentResult) + self.assertFalse(result.success) + mock_secho.assert_has_calls( + [ + call("Failed to deploy image!", fg="red"), + call("Command 'test-exception' returned non-zero exit status 1.", fg="red"), + ] + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_http_s(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="http") + connection_info_http = handler._get_connection_info("127.0.0.1", 8080) + self.assertEqual("http://127.0.0.1:8080", connection_info_http) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="https") + connection_info_https = handler._get_connection_info("127.0.0.1", 8443) + self.assertEqual("https://127.0.0.1:8443", connection_info_https) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_does_not_assign_standard_ports(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="http") + connection_info_http = handler._get_connection_info("127.0.0.1", 80) + self.assertEqual("http://127.0.0.1", connection_info_http) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="https") + connection_info_https = handler._get_connection_info("127.0.0.1", 443) + self.assertEqual("https://127.0.0.1", connection_info_https) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_tcp(self, *args, **kwargs): + challenge = Challenge(self.challenge_path) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1", protocol="tcp") + connection_info = handler._get_connection_info("127.0.0.1", 9001) + self.assertEqual("nc 127.0.0.1 9001", connection_info) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=challenge_directory) + def test_get_connection_info_fallback(self, *args, **kwargs): + challenge = Challenge(self.challenge_path, {"protocol": None}) + + handler = SSHDeploymentHandler(challenge, host="ssh://root@127.0.0.1") + connection_info = handler._get_connection_info("127.0.0.1", 9001) + self.assertEqual("127.0.0.1", connection_info) diff --git a/tests/core/test_api.py b/tests/core/test_api.py new file mode 100644 index 0000000..152a220 --- /dev/null +++ b/tests/core/test_api.py @@ -0,0 +1,173 @@ +import unittest +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.api import API + + +class MockConfigSection(dict): + def __init__(self, data): + super(MockConfigSection, self).__init__(data) + + # this is a wrong implementation but all that's necessary for this test + def getboolean(self, key, default=None): + if key not in self: + return default + + return self[key] + + +class TestAPI(unittest.TestCase): + def test_api_object_ensures_trailing_slash_on_prefix_url(self): + test_urls = [ + "https://example.com/test/", + "https://example.com/test", + "https://example.com/test////", + ] + + for url in test_urls: + mock_config = {"config": MockConfigSection({"url": url, "access_token": "test"})} + + with mock.patch("ctfcli.core.api.Config", return_value=mock_config): + api = API() + self.assertEqual(api.prefix_url, "https://example.com/test/") + + @mock.patch( + "ctfcli.core.api.Config", + return_value=MockConfigSection( + {"config": MockConfigSection({"url": "https://example.com/test", "access_token": "test"})} + ), + ) + @mock.patch("ctfcli.core.api.Session.request") + def test_api_object_request_strips_preceding_slash_from_url_path(self, mock_request: MagicMock, *args, **kwargs): + api = API() + api.request("GET", "/path") + api.request("GET", "path") + + mock_request.assert_has_calls( + [ + call("GET", "https://example.com/test/path", headers={"Content-Type": "application/json"}), + call("GET", "https://example.com/test/path", headers={"Content-Type": "application/json"}), + ] + ) + + @mock.patch( + "ctfcli.core.api.Config", + return_value={"config": MockConfigSection({"url": "https://example.com/test", "access_token": "test"})}, + ) + @mock.patch("ctfcli.core.api.Session.request") + def test_api_object_request_assigns_prefix_url(self, mock_request: MagicMock, *args, **kwargs): + api = API() + api.request("GET", "path") + mock_request.assert_called_once_with( + "GET", "https://example.com/test/path", headers={"Content-Type": "application/json"} + ) + + def test_api_object_assigns_ssl_verify(self, *args, **kwargs): + with mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + } + ) + }, + ): + api = API() + # expect the default to be true + self.assertTrue(api.verify) + + with mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + "ssl_verify": True, + } + ) + }, + ): + api = API() + self.assertTrue(api.verify) + + with mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + "ssl_verify": False, + } + ) + }, + ): + api = API() + self.assertFalse(api.verify) + + @mock.patch("ctfcli.core.api.Config") + def test_api_expects_value_error(self, mock_config_constructor: MagicMock): + mock_config_constructor.return_value = { + "config": MagicMock( + getboolean=MagicMock(side_effect=ValueError("Invalid boolean value")), + get=MagicMock(return_value="/tmp/certificate"), + ) + } + + api = API() + self.assertEqual("/tmp/certificate", api.verify) + + @mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + } + ) + }, + ) + def test_api_object_assigns_headers(self, *args, **kwargs): + api = API() + + self.assertIn("Authorization", api.headers) + self.assertEqual("Token test", api.headers["Authorization"]) + + @mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/test", + "access_token": "test", + } + ), + "cookies": MockConfigSection({"test-cookie": "test-value"}), + }, + ) + def test_api_object_assigns_cookies(self, *args, **kwargs): + api = API() + self.assertIn("test-cookie", api.cookies) + self.assertEqual(api.cookies["test-cookie"], "test-value") + + @mock.patch( + "ctfcli.core.api.Config", + return_value={ + "config": MockConfigSection( + { + "url": "https://example.com/", + "access_token": "test", + } + ), + }, + ) + @mock.patch("ctfcli.core.api.Session.request") + def test_request_does_not_override_form_data_content_type(self, mock_request: MagicMock, *args, **kwargs): + api = API() + api.request("GET", "/test", data="some-file") + mock_request.assert_called_once_with("GET", "https://example.com/test", data="some-file") diff --git a/tests/core/test_challenge.py b/tests/core/test_challenge.py new file mode 100644 index 0000000..3b2ef8c --- /dev/null +++ b/tests/core/test_challenge.py @@ -0,0 +1,1427 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import ANY, MagicMock, call + +from ctfcli.core.challenge import Challenge +from ctfcli.core.exceptions import ( + InvalidChallengeFile, + LintException, + RemoteChallengeNotFound, +) +from ctfcli.core.image import Image + +BASE_DIR = Path(__file__).parent.parent + + +class TestLocalChallengeLoading(unittest.TestCase): + def test_determines_challenge_directory(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + challenge = Challenge(challenge_path) + + self.assertIsInstance(challenge.challenge_file_path, Path) + self.assertEqual(challenge.challenge_directory, BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal") + + def test_raises_if_challenge_yml_does_not_exist(self): + invalid_challenge_path = BASE_DIR / "fixtures" / "challenges" / "nonexistent" / "challenge.yml" + + with self.assertRaises( + InvalidChallengeFile, msg=f"Challenge file at {invalid_challenge_path} could not be found" + ): + Challenge(invalid_challenge_path) + + def test_raises_if_challenge_yml_is_invalid(self): + empty_challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-invalid" / "challenge-empty.yml" + with self.assertRaises(InvalidChallengeFile): + Challenge(empty_challenge_path) + + invalid_challenge_path = ( + BASE_DIR / "fixtures" / "challenges" / "test-challenge-invalid" / "challenge-invalid.yml" + ) # noqa + with self.assertRaises(InvalidChallengeFile): + Challenge(invalid_challenge_path) + + def test_accepts_path_as_string_and_pathlike(self): + challenge_pathlike = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + Challenge(challenge_pathlike) + + challenge_path = str(challenge_pathlike) + Challenge(challenge_path) + + def test_load_challenge(self): + challenge_path = str(BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml") + challenge = Challenge(challenge_path) + + self.assertEqual(challenge["name"], "Test Challenge") + + def test_creates_image_if_specified(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" / "challenge.yml" + challenge = Challenge(challenge_path) + + self.assertIsInstance(challenge.image, Image) + self.assertEqual(challenge.image.name, "test-challenge") + self.assertFalse(challenge.image.built) + + def test_does_not_create_image_if_not_specified(self): + challenge_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + challenge = Challenge(challenge_path) + + self.assertIsNone(challenge.image) + + +class TestRemoteChallengeLoading(unittest.TestCase): + @mock.patch("ctfcli.core.challenge.API") + def test_load_installed_challenge(self, mock_api: MagicMock): + Challenge.load_installed_challenge(1) + + mock_get = mock_api.return_value.get + mock_get.assert_called_once_with("/api/v1/challenges/1") + + @mock.patch("ctfcli.core.challenge.API") + def test_load_installed_challenges(self, mock_api: MagicMock): + Challenge.load_installed_challenges() + + mock_get = mock_api.return_value.get + mock_get.assert_called_once_with("/api/v1/challenges?view=admin") + + +class TestSyncChallenge(unittest.TestCase): + installed_challenges = [ + { + "id": 1, + "type": "standard", + "name": "Test Challenge", + "value": 100, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "files": [ + "/files/e3a267d9cc21ae3051b6d7ea09e5c6cc/old-test.png", + "/files/37b9992954f1e6e64e46af6600fb2c0b/old-test.pdf", + ], + "template": "view.html", + "script": "view.js", + }, + { + "id": 2, + "type": "standard", + "name": "Other Test Challenge", + "value": 150, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + { + "id": 3, + "type": "standard", + "name": "Yet Another Test Challenge", + "value": 200, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + ] + + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + files_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-files" / "challenge.yml" + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_simple_properties(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "state": "visible", + "connection_info": "https://example.com", + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "connection_info": "https://example.com", + "max_attempts": 0, + } + + mock_api: MagicMock = mock_api_constructor.return_value + challenge.sync() + + # expect GET calls loading existing resources to check if something needs to be deleted + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_attempts(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"attempts": 5}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 5, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_extra_properties(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "type": "application_target", + "extra": {"application_spec": "application-spec", "application_name": "application-name"}, + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "value": 150, + "state": "hidden", + "type": "application_target", + "application_spec": "application-spec", + "application_name": "application-name", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_flags(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "flags": [ + "flag{test-flag}", + { + "type": "static", + "content": "flag{test-static}", + "data": "case_insensitive", + }, + { + "type": "regex", + "content": "flag{test-regex-.*}", + "data": "case_insensitive", + }, + ] + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + {"challenge_id": 1, "id": 1, "type": "static", "content": "flag{old-flag}", "data": "", "challenge": 1}, + { + "challenge_id": 1, + "id": 2, + "type": "regex", + "content": "flag{.*}", + "data": "case_insensitive", + "challenge": 1, + }, + ], + } + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/flags", json={"content": "flag{test-flag}", "type": "static", "challenge_id": 1}), + call().raise_for_status(), + call( + "/api/v1/flags", + json={ + "content": "flag{test-static}", + "type": "static", + "data": "case_insensitive", + "challenge_id": 1, + }, + ), + call().raise_for_status(), + call( + "/api/v1/flags", + json={ + "content": "flag{test-regex-.*}", + "type": "regex", + "data": "case_insensitive", + "challenge_id": 1, + }, + ), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/flags/1"), + call().raise_for_status(), + call("/api/v1/flags/2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_topics(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"topics": ["new-topic-1", "new-topic-2"]}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + {"id": 1, "challenge_id": 1, "topic_id": 1, "value": "topic-1"}, + {"id": 2, "challenge_id": 1, "topic_id": 2, "value": "topic-2"}, + ], + } + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/topics", json={"value": "new-topic-1", "type": "challenge", "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/topics", json={"value": "new-topic-2", "type": "challenge", "challenge_id": 1}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/topics?type=challenge&target_id=1"), + call().raise_for_status(), + call("/api/v1/topics?type=challenge&target_id=2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_tags(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"tags": ["new-tag-1", "new-tag-2"]}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + {"id": 1, "challenge_id": 1, "tag_id": 1, "value": "tag-1"}, + {"id": 2, "challenge_id": 1, "tag_id": 2, "value": "tag-2"}, + ], + } + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/tags", json={"value": "new-tag-1", "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/tags", json={"value": "new-tag-2", "challenge_id": 1}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/tags/1"), + call().raise_for_status(), + call("/api/v1/tags/2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_files(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.files_challenge) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges/1": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": self.installed_challenges[0]} + return mock_response + + if path == "/api/v1/files?type=challenge": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [ + {"id": 1, "type": "challenge", "location": "e3a267d9cc21ae3051b6d7ea09e5c6cc/old-test.png"}, + {"id": 2, "type": "challenge", "location": "37b9992954f1e6e64e46af6600fb2c0b/old-test.pdf"}, + ], + } + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/files", files=ANY, data={"challenge_id": 1, "type": "challenge"}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/files/1"), + call().raise_for_status(), + call("/api/v1/files/2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_exits_if_updated_files_do_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"files": ["files/nonexistent.png"]}) + + mock_api: MagicMock = mock_api_constructor.return_value + + with self.assertRaises(InvalidChallengeFile) as e: + challenge.sync() + self.assertEqual(e.exception.message, "File files/nonexistent.png could not be loaded") + + mock_api.get.assert_not_called() + mock_api.patch.assert_not_called() + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_hints(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "hints": [ + "free hint", + { + "content": "paid hint", + "cost": 100, + }, + ] + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + {"challenge_id": 1, "id": 1, "content": "old free hint", "cost": 0, "challenge": 1}, + {"challenge_id": 1, "id": 2, "content": "old paid hint", "cost": 50, "challenge": 1}, + ], + } + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/hints", json={"content": "free hint", "cost": 0, "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/hints", json={"content": "paid hint", "cost": 100, "challenge_id": 1}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_has_calls( + [ + call("/api/v1/hints/1"), + call().raise_for_status(), + call("/api/v1/hints/2"), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_requirements(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"requirements": ["Other Test Challenge", 3]}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + ] + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + # challenge 2 retrieved by name, and challenge 3 retrieved by id + call("/api/v1/challenges/1", json={"requirements": {"prerequisites": [2, 3]}}), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.click.secho") + @mock.patch("ctfcli.core.challenge.API") + def test_challenge_cannot_require_itself( + self, mock_api_constructor: MagicMock, mock_secho: MagicMock, *args, **kwargs + ): + challenge = Challenge(self.minimal_challenge, {"requirements": ["Test Challenge", 2]}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges/1": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": self.installed_challenges[0]} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + + challenge.sync() + + mock_secho.assert_called_once_with( + "Challenge cannot require itself. Skipping invalid requirement.", fg="yellow" + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"requirements": {"prerequisites": [2]}}), + call().raise_for_status(), + ], + any_order=True, + ) + + # test invalid requirement has not been set + self.assertNotIn( + call("/api/v1/challenges/1", json={"requirements": {"prerequisites": [1, 2]}}), + mock_api.patch.call_args_list, + ) + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + ] + ) + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_defaults_to_standard_challenge_type(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + del challenge["type"] + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_defaults_to_visible_state(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + del challenge["state"] + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "max_attempts": 0, + "connection_info": None, + # initial patch should set the state to hidden for the duration of the update + "state": "hidden", + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + # this tests the real assigned state + call("/api/v1/challenges/1", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_does_not_update_dynamic_value(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "value": None, + "type": "dynamic", + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "dynamic", + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/1", json=expected_challenge_payload), call().raise_for_status()] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=[]) + @mock.patch("ctfcli.core.challenge.API") + def test_exits_if_challenges_do_not_exist(self, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + + with self.assertRaises(RemoteChallengeNotFound) as e: + challenge.sync() + self.assertEqual(e.exception.message, "Could not load any remote challenges") + + @mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenges", + return_value=[{"id": 1337, "name": "Dummy Challenge"}], + ) + @mock.patch("ctfcli.core.challenge.API") + def test_exits_if_challenge_does_not_exist(self, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + with self.assertRaises(RemoteChallengeNotFound) as e: + challenge.sync() + self.assertEqual(e.exception.message, "Could not load remote challenge with name 'Test Challenge'") + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_multiple_attributes_at_once(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.files_challenge, + { + "state": "visible", + "attempts": 5, + "connection_info": "https://example.com", + "flags": ["flag{test-flag}"], + # files are defined in the test challenge.yml, but they are provided here too for clarity + "files": ["files/test.png", "files/test.pdf"], + "topics": ["new-topic-1"], + "tags": ["new-tag-1"], + "hints": ["free hint"], + "requirements": [2], + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 5, + "connection_info": "https://example.com", + } + + mock_api: MagicMock = mock_api_constructor.return_value + + challenge.sync() + + mock_api.get.assert_has_calls( + [ + call("/api/v1/challenges/1"), + call("/api/v1/flags"), + call("/api/v1/challenges/1/topics"), + call("/api/v1/tags"), + call("/api/v1/files?type=challenge"), + call("/api/v1/hints"), + ], + any_order=True, + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"requirements": {"prerequisites": [2]}}), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + + mock_api.post.assert_has_calls( + [ + call("/api/v1/flags", json={"content": "flag{test-flag}", "type": "static", "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/topics", json={"value": "new-topic-1", "type": "challenge", "challenge_id": 1}), + call().raise_for_status(), + call("/api/v1/tags", json={"challenge_id": 1, "value": "new-tag-1"}), + call().raise_for_status(), + call("/api/v1/files", files=ANY, data={"challenge_id": 1, "type": "challenge"}), + call().raise_for_status(), + call("/api/v1/hints", json={"content": "free hint", "cost": 0, "challenge_id": 1}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_not_called() + + def test_does_not_update_ignored_attributes(self): + properties = [ + # fmt: off + # simple types + "category", "description", "type", "value", "attempts", "connection_info", "state", + # complex types + "extra", "flags", "topics", "tags", "files", "hints", "requirements", + # fmt: on + ] + + remote_installed_challenge = { + "name": "Test Challenge", + "category": "Old Category", + "description": "Old Description", + "type": "some-custom-type", + "value": 100, + "state": "visible", + "max_attempts": 0, + "connection_info": None, + } + + # This nightmare is necessary because on python 3.8 for whatever reason "with" with multiple context managers + # doesn't work + for p in properties: + with mock.patch("ctfcli.core.challenge.API") as mock_api_constructor: + with mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenge", return_value=remote_installed_challenge + ) as mock_load_installed_challenge: + with mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenges", + return_value=self.installed_challenges, + ) as mock_load_installed_challenges: + challenge = Challenge( + self.minimal_challenge, + { + "state": "visible", + }, + ) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + # expect the payload to modify values with new ones from challenge.yml + # except the ignored property + + # expect these to be in the payload, with the values as on the remote (unchanged): + if p == "value": + expected_challenge_payload["value"] = remote_installed_challenge["value"] + challenge["value"] = 200 + + if p in ["category", "description", "type"]: + expected_challenge_payload[p] = remote_installed_challenge[p] + challenge[p] = "new-value" + + # expect these are just not modified + # (not included in the payload or not modified with requests): + # in case of attempts and connection_info we have to explicitly delete them from the payload + # as they are expected to be present in their default value with all other requests + if p == "attempts": + challenge["attempts"] = 5 + del expected_challenge_payload["max_attempts"] + + if p == "connection_info": + challenge["connection_info"] = "https://example.com" + del expected_challenge_payload["connection_info"] + + if p == "state": + challenge[p] = "new-value" + + if p == "extra": + challenge["extra"] = {"new-value": "new-value"} + + if p in ["flags", "topics", "tags", "files", "hints", "requirements"]: + challenge[p] = ["new-value"] + + challenge.sync(ignore=[p]) + + mock_api: MagicMock = mock_api_constructor.return_value + mock_load_installed_challenge.assert_has_calls([call(1)]) + mock_load_installed_challenges.assert_called_once_with() + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json=expected_challenge_payload), + call().raise_for_status(), + call("/api/v1/challenges/1", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + +class TestCreateChallenge(unittest.TestCase): + installed_challenges = [ + { + "id": 1, + "type": "standard", + "name": "Test Challenge", + "value": 150, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + { + "id": 2, + "type": "standard", + "name": "Other Test Challenge", + "value": 200, + "solves": 0, + "solved_by_me": False, + "category": "test", + "tags": [], + "template": "view.html", + "script": "view.js", + }, + ] + + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + full_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-full" / "challenge.yml" + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_creates_standard_challenge(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.full_challenge) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "Test", + "description": "Test Description", + "value": 150, + "max_attempts": 5, + "type": "standard", + "connection_info": "https://example.com", + "extra_property": "extra_property_value", + "state": "hidden", + } + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 3}} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.post.side_effect = mock_post + + challenge.create() + + mock_api.get.assert_not_called() + mock_api.post.assert_has_calls( + [ + call("/api/v1/challenges", json=expected_challenge_payload), + # flags + call("/api/v1/flags", json={"type": "static", "content": "flag{test-flag}", "challenge_id": 3}), + call( + "/api/v1/flags", + json={ + "type": "static", + "content": "flag{test-static}", + "data": "case_insensitive", + "challenge_id": 3, + }, + ), + call( + "/api/v1/flags", + json={ + "type": "regex", + "content": "flag{test-regex-.*}", + "data": "case_insensitive", + "challenge_id": 3, + }, + ), + # topics + call("/api/v1/topics", json={"value": "topic-1", "type": "challenge", "challenge_id": 3}), + call("/api/v1/topics", json={"value": "topic-2", "type": "challenge", "challenge_id": 3}), + # tags + call("/api/v1/tags", json={"challenge_id": 3, "value": "tag-1"}), + call("/api/v1/tags", json={"challenge_id": 3, "value": "tag-2"}), + # files + call("/api/v1/files", files=ANY, data={"challenge_id": 3, "type": "challenge"}), + # hints + call("/api/v1/hints", json={"content": "free hint", "cost": 0, "challenge_id": 3}), + call("/api/v1/hints", json={"content": "paid hint", "cost": 100, "challenge_id": 3}), + ] + ) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/3", json={"requirements": {"prerequisites": [1, 2]}}), + call().raise_for_status(), + call("/api/v1/challenges/3", json={"state": "visible"}), + call().raise_for_status(), + ] + ) + + mock_api.delete.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_exits_if_files_do_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"files": ["files/nonexistent.png"]}) + mock_api: MagicMock = mock_api_constructor.return_value + + with self.assertRaises(InvalidChallengeFile) as e: + challenge.create() + self.assertEqual(e.exception.message, "File files/nonexistent.png could not be loaded") + + mock_api.get.assert_not_called() + mock_api.patch.assert_not_called() + mock_api.post.assert_not_called() + mock_api.delete.assert_not_called() + + def test_does_not_set_ignored_attributes(self): + # fmt:off + properties = [ + "value", "category", "description", "attempts", "connection_info", "state", # simple types + "extra", "flags", "topics", "tags", "files", "hints", "requirements" # complex types + ] + # fmt:on + + for p in properties: + with mock.patch("ctfcli.core.challenge.API") as mock_api_constructor: + with mock.patch("ctfcli.core.challenge.click.secho") as mock_secho: + with mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenges", + return_value=self.installed_challenges, + ): + challenge = Challenge(self.minimal_challenge, {"state": "visible"}) + + expected_challenge_payload = { + "name": "Test Challenge", + "category": "New Test", + "description": "New Test Description", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + } + + # add a property that should be defined but ignored + + # expect a warning, and to disobey the ignore directive + if p == "value": + challenge["value"] = 200 + expected_challenge_payload["value"] = 200 + + # expect a warning, and to disobey the ignore directive + if p == "type": + challenge["type"] = "custom-type" + expected_challenge_payload[p] = "custom-type" + + # expect these to be in the payload, with the defaults or empty: + if p in ["category", "description"]: + challenge[p] = "new-value" + expected_challenge_payload[p] = "" + + # expect these are just not modified + # (not included in the payload or not modified with requests): + if p == "attempts": + challenge["attempts"] = 5 + del expected_challenge_payload["max_attempts"] + + if p == "connection_info": + challenge["connection_info"] = "https://example.com" + del expected_challenge_payload["connection_info"] + + if p == "state": + challenge[p] = "new-value" + + if p == "extra": + challenge["extra"] = {"new-value": "new-value"} + + if p in ["flags", "topics", "tags", "files", "hints", "requirements"]: + challenge[p] = ["new-value"] + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 3}} + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.post.side_effect = mock_post + + challenge.create(ignore=[p]) + + if p == "value" or p == "type": + mock_secho.assert_called_once_with( + f"Attribute '{p}' cannot be ignored when creating a challenge", fg="yellow" + ) + + # if the state is ignored, expect to default to visible and un-hide the challenge + mock_api.patch.assert_has_calls( + [call("/api/v1/challenges/3", json={"state": "visible"}), call().raise_for_status()] + ) + mock_api.post.assert_called_once_with("/api/v1/challenges", json=expected_challenge_payload) + mock_api.get.assert_not_called() + mock_api.delete.assert_not_called() + + +class TestLintChallenge(unittest.TestCase): + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + files_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-files" / "challenge.yml" + dockerfile_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" / "challenge.yml" + invalid_dockerfile_challenge = ( + BASE_DIR / "fixtures" / "challenges" / "test-challenge-invalid-dockerfile" / "challenge.yml" + ) + + def test_validates_required_fields(self): + required_fields = ["name", "author", "category", "description", "value"] + for field in required_fields: + challenge = Challenge(self.minimal_challenge) + del challenge[field] + + with self.assertRaises(LintException) as e: + challenge.lint() + + expected_lint_issues = { + "fields": [f"challenge.yml is missing required field: {field}"], + "dockerfile": [], + "hadolint": [], + "files": [], + } + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + def test_validates_challenge_yml_points_to_dockerfile(self): + challenge = Challenge(self.minimal_challenge, {"image": "."}) + + with self.assertRaises(LintException) as e: + challenge.lint() + + expected_lint_issues = { + "fields": [], + "dockerfile": ["Dockerfile specified in 'image' field but no Dockerfile found"], + "hadolint": [], + "files": [], + } + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + def test_validates_challenge_yml_does_not_point_to_dockerfile(self): + challenge = Challenge(self.dockerfile_challenge) + del challenge["image"] + + with self.assertRaises(LintException) as e: + challenge.lint() + + expected_lint_issues = { + "fields": [], + "dockerfile": ["Dockerfile exists but image field does not point to it"], + "hadolint": [], + "files": [], + } + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + @mock.patch("ctfcli.core.challenge.click.secho") + def test_validates_dockerfile_exposes_port(self, mock_secho: MagicMock): + challenge = Challenge(self.invalid_dockerfile_challenge) + + with self.assertRaises(LintException) as e: + challenge.lint(skip_hadolint=True) + + expected_lint_issues = { + "fields": [], + "dockerfile": ["Dockerfile is missing EXPOSE"], + "hadolint": [], + "files": [], + } + + mock_secho.assert_called_once_with("Skipping Hadolint", fg="yellow") + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + @mock.patch("ctfcli.core.challenge.subprocess.run") + def test_runs_hadolint(self, mock_run: MagicMock): + class RunResult: + def __init__(self, return_code): + self.returncode = return_code + self.stdout = b"-:1 DL3006 warning: Always tag the version of an image explicitly" + + mock_run.return_value = RunResult(1) + challenge = Challenge(self.dockerfile_challenge) + + with self.assertRaises(LintException) as e: + challenge.lint() + + mock_run.assert_called_once_with( + ["docker", "run", "--rm", "-i", "hadolint/hadolint"], stdout=-1, stderr=-1, input=ANY + ) + + expected_lint_issues = { + "fields": [], + "dockerfile": [], + "hadolint": ["-:1 DL3006 warning: Always tag the version of an image explicitly"], + "files": [], + } + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + @mock.patch("ctfcli.core.challenge.subprocess.run") + @mock.patch("ctfcli.core.challenge.click.secho") + def test_allows_for_skipping_hadolint(self, mock_secho: MagicMock, mock_run: MagicMock, *args, **kwargs): + challenge = Challenge(self.dockerfile_challenge) + result = challenge.lint(skip_hadolint=True) + + mock_secho.assert_called_once_with("Skipping Hadolint", fg="yellow") + mock_run.assert_not_called() + self.assertTrue(result) + + @mock.patch("ctfcli.core.challenge.click.secho") + def test_validates_files_exist(self, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"files": ["files/nonexistent.pdf"]}) + + with self.assertRaises(LintException) as e: + challenge.lint(skip_hadolint=True) + + expected_file_path = (challenge.challenge_directory / "files" / "nonexistent.pdf").absolute() + expected_lint_issues = { + "fields": [], + "dockerfile": [], + "hadolint": [], + "files": [f"Challenge file 'files/nonexistent.pdf' specified, but not found at {expected_file_path}"], + } + + self.assertDictEqual(expected_lint_issues, e.exception.issues) + + @mock.patch("ctfcli.core.challenge.click.secho") + def test_looks_for_flags_in_dist_files(self, *args, **kwargs): + challenge = Challenge(self.files_challenge, {"files": ["files/flag.txt"]}) + + with self.assertRaises(LintException) as e: + challenge.lint(skip_hadolint=True) + + expected_lint_issues = { + "fields": [], + "dockerfile": [], + "hadolint": [], + "files": ["Potential flag found in distributed file 'files/flag.txt':\n Whoopsie: flag{test-flag}"], + } + + self.assertDictEqual(expected_lint_issues, e.exception.issues) diff --git a/tests/core/test_config.py b/tests/core/test_config.py new file mode 100644 index 0000000..1082d08 --- /dev/null +++ b/tests/core/test_config.py @@ -0,0 +1,193 @@ +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock + +from ctfcli.core.config import Config +from ctfcli.core.exceptions import ProjectNotInitialized + +BASE_DIR = Path(__file__).parent.parent + + +class TestConfig(unittest.TestCase): + minimal_challenge_cwd = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=Path(tempfile.mkdtemp())) + def test_raises_if_config_is_not_found(self, *args, **kwargs): + with self.assertRaises(ProjectNotInitialized): + Config() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_determines_project_path(self, *args, **kwargs): + config = Config() + expected_project_path = BASE_DIR / "fixtures" / "challenges" + self.assertEqual(expected_project_path, config.project_path) + self.assertEqual(expected_project_path, config.get_project_path()) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_determines_base_path(self, *args, **kwargs): + config = Config() + expected_base_path = BASE_DIR.parent / "ctfcli" + self.assertEqual(expected_base_path, config.base_path) + self.assertEqual(expected_base_path, config.get_base_path()) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_determines_config_path(self, *args, **kwargs): + config = Config() + expected_config_path = BASE_DIR / "fixtures" / "challenges" / ".ctf" / "config" + self.assertEqual(expected_config_path, config.config_path) + self.assertEqual(expected_config_path, config.get_config_path()) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.config.configparser.ConfigParser") + def test_reads_config(self, mock_configparser: MagicMock, *args, **kwargs): + Config() + expected_config_path = BASE_DIR / "fixtures" / "challenges" / ".ctf" / "config" + mock_configparser.return_value.read.assert_called_once_with(expected_config_path) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.config.configparser.ConfigParser") + def test_writes_config(self, mock_configparser: MagicMock, *args, **kwargs): + config = Config() + + with tempfile.NamedTemporaryFile(delete=True) as tmp_config: + config.write(tmp_config) + mock_configparser.return_value.write.assert_called_once_with(tmp_config) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_returns_correct_json_representation(self, *args, **kwargs): + config = Config() + + expected_config = { + "config": {"access_token": "deadbeef", "url": "https://example.com/"}, + "challenges": { + "test-challenge-dockerfile": "user@host:example/test-challenge-dockerfile.git", + "test-challenge-files": "user@host:example/test-challenge-files.git", + "test-challenge-full": "user@host:example/test-challenge-full.git", + "test-challenge-minimal": "user@host:example/test-challenge-minimal.git", + }, + } + + config_data = json.loads(config.as_json()) + self.assertEqual(expected_config, config_data) + + config_data_pretty = json.loads(config.as_json(pretty=True)) + self.assertEqual(expected_config, config_data_pretty) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_overloads_getitem(self, *args, **kwargs): + config = Config() + + # test that config can be directly accessed like a dictionary + self.assertEqual(config["config"]["url"], "https://example.com/") + self.assertEqual( + config["challenges"]["test-challenge-minimal"], + "user@host:example/test-challenge-minimal.git", + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_overloads_contains(self, *args, **kwargs): + config = Config() + # test that config can be queried with 'in' + self.assertTrue("challenges" in config) + + def test_get_data_path_returns_path(self): + data_path = Config.get_data_path() + self.assertIsInstance(data_path, Path) + + def test_get_templates_path_returns_path(self): + templates_path = Config.get_templates_path() + self.assertIsInstance(templates_path, Path) + + @mock.patch("ctfcli.core.config.appdirs.user_data_dir") + def test_get_data_path_calls_appdirs(self, mock_user_data_path: MagicMock): + Config.get_data_path() + mock_user_data_path.assert_called_once_with(appname="ctfcli") + + @mock.patch( + "ctfcli.core.config.Config.get_data_path", + return_value=Path("/tmp/test/ctfcli-data"), + ) + @mock.patch.object(Path, "mkdir") + @mock.patch.object(Path, "exists") + def test_get_templates_path(self, mock_exists: MagicMock, mock_mkdir: MagicMock, *args, **kwargs): + expected_templates_path = Path("/tmp/test/ctfcli-data/templates") + mock_exists.return_value = False + + templates_path = Config.get_templates_path() + + self.assertEqual(templates_path, expected_templates_path) + mock_mkdir.assert_called_once() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_get_pages_path_returns_path(self, *args, **kwargs): + pages_path = Config.get_pages_path() + self.assertIsInstance(pages_path, Path) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + def test_get_pages_path(self, *args, **kwargs): + expected_pages_path = BASE_DIR / "fixtures" / "challenges" / "pages" + pages_path = Config.get_pages_path() + self.assertEqual(expected_pages_path, pages_path) + + @mock.patch( + "ctfcli.core.config.Config.get_project_path", + return_value=Path("/tmp/test/ctfcli-project"), + ) + @mock.patch.object(Path, "mkdir") + @mock.patch.object(Path, "exists") + def test_get_pages_path_creates_directory(self, mock_exists: MagicMock, mock_mkdir: MagicMock, *args, **kwargs): + expected_pages_path = Path("/tmp/test/ctfcli-project/pages") + mock_exists.return_value = False + + pages_path = Config.get_pages_path() + + self.assertEqual(pages_path, expected_pages_path) + mock_mkdir.assert_called_once() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch( + "ctfcli.core.config.Config.get_data_path", + return_value=Path("/tmp/test/ctfcli-data"), + ) + def test_get_plugins_path(self, *args, **kwargs): + expected_plugins_path = Path("/tmp/test/ctfcli-data") / "plugins" + pages_path = Config.get_plugins_path() + self.assertEqual(expected_plugins_path, pages_path) + + @mock.patch( + "ctfcli.core.config.Config.get_data_path", + return_value=Path("/tmp/test/ctfcli-data"), + ) + @mock.patch.object(Path, "mkdir") + @mock.patch.object(Path, "exists") + def test_get_plugins_path_creates_directory(self, mock_exists: MagicMock, mock_mkdir: MagicMock, *args, **kwargs): + expected_plugins_path = Path("/tmp/test/ctfcli-data/plugins") + mock_exists.return_value = False + + plugins_path = Config.get_plugins_path() + + self.assertEqual(expected_plugins_path, plugins_path) + mock_mkdir.assert_called_once() + + @mock.patch.dict(os.environ, {"CTFCLI_PLUGIN_PATH": "/tmp/test/custom-plugins-directory"}) + @mock.patch.object(Path, "exists") + def test_get_plugins_path_uses_overriden_directory(self, mock_exists: MagicMock, *args, **kwargs): + expected_plugins_path = Path("/tmp/test/custom-plugins-directory") + mock_exists.return_value = True + + plugins_path = Config.get_plugins_path() + self.assertEqual(expected_plugins_path, plugins_path) + + @mock.patch.dict(os.environ, {"CTFCLI_PLUGIN_PATH": "custom-plugins-directory"}) + @mock.patch.object(Path, "exists") + def test_get_plugins_path_accepts_relative_directory_override(self, mock_exists: MagicMock, *args, **kwargs): + expected_plugins_path = BASE_DIR.parent / "ctfcli" / "custom-plugins-directory" + mock_exists.return_value = True + + plugins_path = Config.get_plugins_path() + + self.assertEqual(expected_plugins_path, plugins_path) diff --git a/tests/core/test_exceptions.py b/tests/core/test_exceptions.py new file mode 100644 index 0000000..1fc5835 --- /dev/null +++ b/tests/core/test_exceptions.py @@ -0,0 +1,56 @@ +import unittest +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.exceptions import LintException + + +class TestLintException(unittest.TestCase): + @mock.patch("ctfcli.core.exceptions.click.echo") + @mock.patch("ctfcli.core.exceptions.click.secho") + def test_print_summary(self, mock_secho: MagicMock, mock_echo: MagicMock): + test_issues = { + "fields": [ + "challenge.yml is missing required field: author", + "challenge.yml is missing required field: name", + "challenge.yml is missing required field: value", + ], + "dockerfile": ["Dockerfile exists but image field does not point to it", "Dockerfile is missing EXPOSE"], + "hadolint": ["-:1 DL3006 warning: Always tag the version of an image explicitly"], + "files": [ + "Challenge file 'files/test-file.png' specified, but not found at /challenge/files/test-file.png" + ], + } + + with self.assertRaises(LintException) as e: + raise LintException(issues=test_issues) + + self.assertDictEqual(test_issues, e.exception.issues) + e.exception.print_summary() + + mock_secho.assert_has_calls( + [ + call("Fields:", fg="yellow"), + call("Dockerfile:", fg="yellow"), + call("Hadolint:", fg="yellow"), + call("Files:", fg="yellow"), + ] + ) + + mock_echo.assert_has_calls( + [ + call(" - challenge.yml is missing required field: author"), + call(" - challenge.yml is missing required field: name"), + call(" - challenge.yml is missing required field: value"), + call(), + call(" - Dockerfile exists but image field does not point to it"), + call(" - Dockerfile is missing EXPOSE"), + call(), + call("-:1 DL3006 warning: Always tag the version of an image explicitly"), + call( + " - Challenge file 'files/test-file.png' specified, " + "but not found at /challenge/files/test-file.png" + ), + call(), + ] + ) diff --git a/tests/core/test_image.py b/tests/core/test_image.py new file mode 100644 index 0000000..d272951 --- /dev/null +++ b/tests/core/test_image.py @@ -0,0 +1,291 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.image import Image + +BASE_DIR = Path(__file__).parent.parent + + +class TestImage(unittest.TestCase): + def test_assigns_attributes(self): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + + self.assertEqual(image.name, "test-challenge") + self.assertEqual(image.build_path, build_path) + self.assertFalse(image.built) + + def test_accepts_path_as_string_and_pathlike(self): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertEqual(image.build_path, build_path) + + build_path_string = str(build_path) + image = Image("test-challenge", build_path_string) + self.assertEqual(image.build_path, build_path) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_build(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_name = image.build() + + self.assertTrue(image.built) + self.assertEqual(image_name, "test-challenge") + mock_call.assert_called_once_with(["docker", "build", "-t", "test-challenge", "."], cwd=build_path.absolute()) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=1) + def test_build_returns_none_if_failed(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_name = image.build() + + self.assertFalse(image.built) + self.assertIsNone(image_name) + mock_call.assert_called_once_with(["docker", "build", "-t", "test-challenge", "."], cwd=build_path.absolute()) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_push_built_image(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_name = image.build() + image_location = image.push("registry.ctfd.io/example-project/test-challenge") + + self.assertEqual("test-challenge", image_name) + self.assertEqual("registry.ctfd.io/example-project/test-challenge", image_location) + self.assertTrue(image.built) + + mock_call.assert_has_calls( + [ + call( + [ + "docker", + "tag", + "test-challenge", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + call( + [ + "docker", + "push", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + ] + ) + + # mock successful build but failed push + @mock.patch("ctfcli.core.image.subprocess.call", side_effect=[0, 1, 1]) + def test_push_returns_none_if_failed(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_name = image.build() + image_location = image.push("registry.ctfd.io/example-project/test-challenge") + + self.assertEqual("test-challenge", image_name) + self.assertIsNone(image_location) + self.assertTrue(image.built) + + mock_call.assert_has_calls( + [ + call( + [ + "docker", + "tag", + "test-challenge", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + call( + [ + "docker", + "push", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_builds_image_before_push(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image_location = image.push("registry.ctfd.io/example-project/test-challenge") + self.assertEqual("registry.ctfd.io/example-project/test-challenge", image_location) + self.assertTrue(image.built) + + mock_call.assert_has_calls( + [ + call( + ["docker", "build", "-t", "test-challenge", "."], + cwd=build_path.absolute(), + ), + call( + [ + "docker", + "tag", + "test-challenge", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + call( + [ + "docker", + "push", + "registry.ctfd.io/example-project/test-challenge", + ] + ), + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_export_built_image(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + image.build() + + mock_named_temporary_file = MagicMock() + mock_named_temporary_file.name = "/tmp/test-challenge.docker.tar" + + with mock.patch( + "ctfcli.core.image.tempfile.NamedTemporaryFile", + return_value=mock_named_temporary_file, + ): + export_path = image.export() + + self.assertTrue(image.built) + self.assertEqual(export_path, "/tmp/test-challenge.docker.tar") + mock_call.assert_has_calls( + [ + call( + [ + "docker", + "save", + "--output", + "/tmp/test-challenge.docker.tar", + "test-challenge", + ] + ) + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call", return_value=0) + def test_builds_image_before_export(self, mock_call: MagicMock): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + mock_named_temporary_file = MagicMock() + mock_named_temporary_file.name = "/tmp/test-challenge.docker.tar" + + with mock.patch( + "ctfcli.core.image.tempfile.NamedTemporaryFile", + return_value=mock_named_temporary_file, + ): + export_path = image.export() + + self.assertTrue(image.built) + self.assertEqual(export_path, "/tmp/test-challenge.docker.tar") + mock_call.assert_has_calls( + [ + call( + ["docker", "build", "-t", "test-challenge", "."], + cwd=build_path.absolute(), + ), + call( + [ + "docker", + "save", + "--output", + "/tmp/test-challenge.docker.tar", + "test-challenge", + ] + ), + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call") + @mock.patch("ctfcli.core.image.subprocess.check_output", return_value='{"80/tcp":{}}') + def test_get_exposed_port(self, mock_check_output: MagicMock, *args, **kwargs): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + exposed_port = image.get_exposed_port() + self.assertEqual("80", exposed_port) + mock_check_output.assert_has_calls( + [ + call( + [ + "docker", + "inspect", + "--format={{json .Config.ExposedPorts}}", + "test-challenge", + ] + ) + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call") + @mock.patch( + "ctfcli.core.image.subprocess.check_output", + return_value='{"80/tcp":{},"8000/tcp":{}}', + ) + def test_get_exposed_port_returns_first_port_if_multiple_exposed( + self, mock_check_output: MagicMock, *args, **kwargs + ): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + exposed_port = image.get_exposed_port() + self.assertEqual("80", exposed_port) + mock_check_output.assert_has_calls( + [ + call( + [ + "docker", + "inspect", + "--format={{json .Config.ExposedPorts}}", + "test-challenge", + ] + ) + ] + ) + + @mock.patch("ctfcli.core.image.subprocess.call") + @mock.patch("ctfcli.core.image.subprocess.check_output", return_value="null") + def test_get_exposed_port_returns_none_if_no_ports_exposed(self, mock_check_output: MagicMock, *args, **kwargs): + build_path = BASE_DIR / "fixtures" / "challenges" / "test-challenge-dockerfile" + image = Image("test-challenge", build_path) + self.assertFalse(image.built) + + exposed_port = image.get_exposed_port() + self.assertIsNone(exposed_port) + mock_check_output.assert_has_calls( + [ + call( + [ + "docker", + "inspect", + "--format={{json .Config.ExposedPorts}}", + "test-challenge", + ] + ) + ] + ) diff --git a/tests/core/test_page.py b/tests/core/test_page.py new file mode 100644 index 0000000..e522f69 --- /dev/null +++ b/tests/core/test_page.py @@ -0,0 +1,767 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, call + +import frontmatter + +from ctfcli.core.exceptions import ( + IllegalPageOperation, + InvalidPageConfiguration, + InvalidPageFormat, +) +from ctfcli.core.page import Page + +BASE_DIR = Path(__file__).parent.parent + + +class TestPage(unittest.TestCase): + # use a test challenge path as cwd to avoid mocking config + minimal_challenge_cwd = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + + def tearDown(self) -> None: + # reset class cache after each test + Page._remote_pages = None + Page._remote_page_ids = None + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_loads_local_markdown_page(self, *args, **kwargs): + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "markdown-page.md" + page = Page(page_path=page_path) + + self.assertEqual("markdown-page", page.route) + self.assertEqual("Markdown Page", page.title) + self.assertEqual("# Hello World!", page.content) + self.assertEqual("markdown", page.format) + self.assertTrue(page.is_draft) + self.assertTrue(page.is_hidden) + self.assertTrue(page.is_auth_required) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_loads_local_html_page(self, *args, **kwargs): + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + + self.assertEqual("html-page", page.route) + self.assertEqual("HTML Page", page.title) + self.assertEqual("

Hello World!

", page.content) + self.assertEqual("html", page.format) + self.assertFalse(page.is_draft) + self.assertFalse(page.is_hidden) + self.assertFalse(page.is_auth_required) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_loads_remote_markdown_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "content": "# Hello World!", + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + } + + page = Page(page_id=1) + + self.assertEqual("markdown-page", page.route) + self.assertEqual("Markdown Page", page.title) + self.assertEqual("# Hello World!", page.content) + self.assertEqual("markdown", page.format) + self.assertTrue(page.is_draft) + self.assertTrue(page.is_hidden) + self.assertTrue(page.is_auth_required) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_loads_remote_html_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + } + + page = Page(page_id=1) + + self.assertEqual("html-page", page.route) + self.assertEqual("HTML Page", page.title) + self.assertEqual("

Hello World!

", page.content) + self.assertEqual("html", page.format) + self.assertFalse(page.is_draft) + self.assertFalse(page.is_hidden) + self.assertFalse(page.is_auth_required) + + def test_raises_if_no_path_or_id_provided(self): + with self.assertRaises(InvalidPageConfiguration): + Page( + page_id=1, + page_path=BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html", + ) + + def test_raises_if_both_path_and_id_provided(self): + with self.assertRaises(InvalidPageConfiguration): + Page() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_as_dict(self, *args, **kwargs): + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "markdown-page.md" + page = Page(page_path=page_path) + + expected_dict = { + "route": "markdown-page", + "title": "Markdown Page", + "content": "# Hello World!", + "format": "markdown", + "draft": True, + "hidden": True, + "auth_required": True, + } + + self.assertDictEqual(expected_dict, page.as_dict()) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_as_frontmatter_post(self, *args, **kwargs): + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "markdown-page.md" + page = Page(page_path=page_path) + + expected_metadata = { + "route": "markdown-page", + "title": "Markdown Page", + "draft": True, + "hidden": True, + "auth_required": True, + } + + page_as_post = page.as_frontmatter_post() + self.assertIsInstance(page_as_post, frontmatter.Post) + self.assertEqual(page_as_post.content, "# Hello World!") + self.assertDictEqual(page_as_post.metadata, expected_metadata) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_syncs_local_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_page_data = { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + mock_api.get.return_value.json.side_effect = [ + # mock first call to /api/v1/pages + {"success": True, "data": [mock_page_data]}, + # mock second call to /api/v1/pages/1 + {"success": True, "data": mock_page_data}, + ] + + mock_api.post.return_value.json.return_value = { + "success": True, + "data": mock_page_data, + } + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + page.sync() + + expected_page_payload = { + "format": "html", + "draft": False, + "title": "HTML Page", + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + mock_api.patch.assert_called_once_with("/api/v1/pages/1", json=expected_page_payload) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_sync_local_page_if_it_does_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_page_data = { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + # mock call to /api/v1/page/1 + mock_api.get.return_value.json.return_value = { + "success": True, + "data": mock_page_data, + } + + page = Page(page_id=1) + + with self.assertRaises(IllegalPageOperation) as e: + page.sync() + + self.assertEqual( + "Cannot sync page 'html-page.html' - local version does not exists. Use pull first.", str(e.exception) + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_sync_local_page_if_remote_does_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + # mock call to /api/v1/pages + mock_api.get.return_value.json.return_value = {"success": True, "data": []} + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + + with self.assertRaises(IllegalPageOperation) as e: + page.sync() + + self.assertEqual( + "Cannot sync page 'html-page.html' - remote version does not exists. Use push first.", str(e.exception) + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_downloads_remote_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "Test Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "test-page", + }, + } + + page = Page(page_id=1) + page.pull() + + expected_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "test-page.html" + self.assertTrue(expected_path.exists()) + + expected_path.unlink() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_downloads_remote_nested_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "Test Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "test/test-page", + }, + } + + page = Page(page_id=1) + page.pull() + + expected_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "test" / "test-page.html" + self.assertTrue(expected_path.exists()) + + expected_path.unlink() + expected_path.parent.rmdir() + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_download_remote_page_if_file_exists(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_page_data = { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": mock_page_data, + } + + page = Page(page_id=1) + with self.assertRaises(IllegalPageOperation) as e: + page.pull() + + self.assertEqual("Cannot pull page 'html-page.html' - file already exists.", str(e.exception)) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.click.secho") + @mock.patch("ctfcli.core.page.API") + def test_downloads_remote_page_with_overwrite( + self, mock_api_constructor: MagicMock, mock_secho: MagicMock, *args, **kwargs + ): + mock_api = mock_api_constructor.return_value + + mock_api.get.return_value.json.return_value = { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + } + + page = Page(page_id=1) + page.pull(overwrite=True) + + mock_secho.assert_called_once_with("Overwriting page file 'html-page.html'", fg="yellow") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_download_remote_page_if_it_does_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = {"success": True, "data": []} + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + + with self.assertRaises(IllegalPageOperation) as e: + page.pull() + + self.assertEqual("Cannot pull page 'html-page.html' - remote version does not exists.", str(e.exception)) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_uploads_local_page(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + page.push() + + expected_page_payload = { + "route": "html-page", + "title": "HTML Page", + "content": "

Hello World!

", + "draft": False, + "hidden": False, + "auth_required": False, + "format": "html", + } + + mock_api.post.assert_called_once_with("/api/v1/pages", json=expected_page_payload) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_cannot_upload_local_page_if_remote_exists(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + + mock_page_data = { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 1, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + } + + mock_api.get.return_value.json.side_effect = [ + # 1st call to /api/v1/pages + {"success": True, "data": [mock_page_data]}, + # 2nd call to /api/v1/pages/1 + {"success": True, "data": mock_page_data}, + ] + + page_path = BASE_DIR / "fixtures" / "challenges" / "pages" / "html-page.html" + page = Page(page_path=page_path) + + with self.assertRaises(IllegalPageOperation) as e: + page.push() + + self.assertEqual( + "Cannot push page 'html-page.html' - remote version exists. Use sync instead.", str(e.exception) + ) + + def test_get_format(self): + formats = { + ".md": "markdown", + ".html": "html", + ".htm": "html", + } + + for ext, fmt in formats.items(): + self.assertEqual(fmt, Page.get_format(ext)) + + def test_get_format_extension(self): + extensions = {"markdown": [".md"], "html": [".html", ".htm"]} + + for fmt, allowed_ext in extensions.items(): + self.assertIn(Page.get_format_extension(fmt), allowed_ext) + + def test_get_format_raises_on_unknown_format(self): + with self.assertRaises(InvalidPageFormat): + Page.get_format(".rst") + + def test_get_format_extension_raises_on_unknown_format(self): + with self.assertRaises(InvalidPageFormat): + Page.get_format("restructured-text") + + +class TestPageLoading(unittest.TestCase): + minimal_challenge_cwd = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + + def tearDown(self) -> None: + # reset class cache after each test + Page._remote_pages = None + Page._remote_page_ids = None + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_local_pages(self, *args, **kwargs): + local_pages = Page.get_local_pages() + + # expect all pages to be of type Page, and expect to find all the titles + expected_page_titles = ["Nested HTML Page", "HTML Page", "Markdown Page"] + for page in local_pages: + expected_page_titles.pop(expected_page_titles.index(page.title)) + self.assertIsInstance(page, Page) + + self.assertEqual(3, len(local_pages)) + self.assertEqual(0, len(expected_page_titles)) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_remote_pages(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.side_effect = [ + { # 1st request - to /api/v1/pages + "success": True, + "data": [ + { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + ], + }, + # subsequent request to fetch pages individually + { + "success": True, + "data": { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "content": "# Hello World!", + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + }, + { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + }, + { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "content": "

Hello Nested!

", + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + }, + ] + + remote_pages = Page.get_remote_pages() + + # expect all pages to be of type Page, and expect to find all the titles + expected_page_titles = ["Nested HTML Page", "HTML Page", "Markdown Page"] + for page in remote_pages: + expected_page_titles.pop(expected_page_titles.index(page.title)) + self.assertIsInstance(page, Page) + + self.assertEqual(3, len(remote_pages)) + self.assertEqual(0, len(expected_page_titles)) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_remote_page_id(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + ], + } + + page_id = Page.get_remote_page_id("nested-html-page") + self.assertEqual(3, page_id) + + mock_api.get.assert_called_once_with("/api/v1/pages") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_remote_page_id_saves_found_id(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = { + "success": True, + "data": [ + { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + ], + } + + markdown_page_id = Page.get_remote_page_id("markdown-page") + self.assertEqual(1, markdown_page_id) + + # expect _remote_pages to be None, as we didn't fetch any full pages - only ids + self.assertIsNone(Page._remote_pages) + + # expect to find all available page ids in the cache + self.assertDictEqual(Page._remote_page_ids, {"markdown-page": 1, "html-page": 2}) + + html_page_id = Page.get_remote_page_id("html-page") + self.assertEqual(2, html_page_id) + + # expect to have only made one call + self.assertEqual(1, mock_api.get.mock_calls.count(call("/api/v1/pages"))) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.page.API") + def test_get_remote_pages_saves_pages(self, mock_api_constructor: MagicMock, *args, **kwargs): + mock_api = mock_api_constructor.return_value + mock_api.get.return_value.json.side_effect = [ + { # 1st request - to /api/v1/pages + "success": True, + "data": [ + { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + ], + }, + # subsequent request to fetch pages individually + { + "success": True, + "data": { + "format": "markdown", + "files": [], + "draft": True, + "title": "Markdown Page", + "id": 1, + "content": "# Hello World!", + "auth_required": True, + "hidden": True, + "route": "markdown-page", + }, + }, + { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "HTML Page", + "id": 2, + "content": "

Hello World!

", + "auth_required": False, + "hidden": False, + "route": "html-page", + }, + }, + { + "success": True, + "data": { + "format": "html", + "files": [], + "draft": False, + "title": "Nested HTML Page", + "id": 3, + "content": "

Hello Nested!

", + "auth_required": False, + "hidden": False, + "route": "nested-html-page", + }, + }, + ] + + remote_pages = Page.get_remote_pages() + + self.assertEqual(3, len(remote_pages)) + # expect to have saved 3 pages + self.assertEqual(3, len(Page._remote_pages)) + + Page.get_remote_page_id("markdown-page") + + # check that /api/v1/pages has only been called once, even though we requested the id for a page + self.assertEqual(1, mock_api.get.mock_calls.count(call("/api/v1/pages"))) diff --git a/tests/core/test_plugins.py b/tests/core/test_plugins.py new file mode 100644 index 0000000..cdbc25d --- /dev/null +++ b/tests/core/test_plugins.py @@ -0,0 +1,22 @@ +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock, call + +from ctfcli.core.config import Config +from ctfcli.core.plugins import load_plugins + + +class TestPlugins(unittest.TestCase): + @mock.patch.object(Config, "get_plugins_path") + @mock.patch("ctfcli.core.plugins.importlib.import_module") + def test_load_plugins(self, mock_import: MagicMock, mock_plugins_path: MagicMock): + mock_plugins_path.return_value = MagicMock() + mock_plugins_path.return_value.absolute.return_value = Path("/tmp/test") + mock_plugins_path.return_value.iterdir.return_value = [Path("/tmp/test/test_plugin")] + + test_commands = {"challenge": None, "pages": None} + load_plugins(test_commands) + + mock_import.assert_has_calls([call("test_plugin")]) + mock_import.return_value.load.assert_called_once_with(test_commands) diff --git a/tests/fixtures/challenges/.ctf/config b/tests/fixtures/challenges/.ctf/config new file mode 100644 index 0000000..923aedd --- /dev/null +++ b/tests/fixtures/challenges/.ctf/config @@ -0,0 +1,10 @@ +[config] +url = https://example.com/ +access_token = deadbeef + +[challenges] +test-challenge-minimal = user@host:example/test-challenge-minimal.git +test-challenge-full = user@host:example/test-challenge-full.git +test-challenge-files = user@host:example/test-challenge-files.git +test-challenge-dockerfile = user@host:example/test-challenge-dockerfile.git + diff --git a/tests/fixtures/challenges/pages/html-page.html b/tests/fixtures/challenges/pages/html-page.html new file mode 100644 index 0000000..f4c9b34 --- /dev/null +++ b/tests/fixtures/challenges/pages/html-page.html @@ -0,0 +1,9 @@ +--- +auth_required: false +draft: false +hidden: false +route: html-page +title: HTML Page +--- + +

Hello World!

\ No newline at end of file diff --git a/tests/fixtures/challenges/pages/markdown-page.md b/tests/fixtures/challenges/pages/markdown-page.md new file mode 100644 index 0000000..e0d1c99 --- /dev/null +++ b/tests/fixtures/challenges/pages/markdown-page.md @@ -0,0 +1,9 @@ +--- +route: markdown-page +title: Markdown Page +draft: true +hidden: true +auth_required: true +--- + +# Hello World! \ No newline at end of file diff --git a/tests/fixtures/challenges/pages/nested/html-page.htm b/tests/fixtures/challenges/pages/nested/html-page.htm new file mode 100644 index 0000000..5741c5e --- /dev/null +++ b/tests/fixtures/challenges/pages/nested/html-page.htm @@ -0,0 +1,9 @@ +--- +route: nested-html-page +title: Nested HTML Page +draft: false +hidden: false +auth_required: false +--- + +

Hello Nested!

\ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-dockerfile/Dockerfile b/tests/fixtures/challenges/test-challenge-dockerfile/Dockerfile new file mode 100644 index 0000000..1b40618 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-dockerfile/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:mainline-alpine + +WORKDIR /usr/share/nginx/html +COPY src/ . + +EXPOSE 80 diff --git a/tests/fixtures/challenges/test-challenge-dockerfile/challenge.yml b/tests/fixtures/challenges/test-challenge-dockerfile/challenge.yml new file mode 100644 index 0000000..4ef74d0 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-dockerfile/challenge.yml @@ -0,0 +1,9 @@ +name: Test Challenge +category: New Test +description: New Test Description +value: 150 +author: Test +type: standard +state: hidden +image: . +protocol: http \ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-dockerfile/src/index.html b/tests/fixtures/challenges/test-challenge-dockerfile/src/index.html new file mode 100644 index 0000000..0cb0db1 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-dockerfile/src/index.html @@ -0,0 +1,13 @@ + + + + + + + Hello Test! + + +

Hello Test!

+ + \ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-files/challenge.yml b/tests/fixtures/challenges/test-challenge-files/challenge.yml new file mode 100644 index 0000000..bd692d0 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-files/challenge.yml @@ -0,0 +1,11 @@ +name: Test Challenge +category: New Test +description: New Test Description +value: 150 +author: Test +type: standard +state: hidden + +files: + - files/test.png + - files/test.pdf \ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-files/files/flag.txt b/tests/fixtures/challenges/test-challenge-files/files/flag.txt new file mode 100644 index 0000000..bf67927 --- /dev/null +++ b/tests/fixtures/challenges/test-challenge-files/files/flag.txt @@ -0,0 +1 @@ +Whoopsie: flag{test-flag} \ No newline at end of file diff --git a/tests/fixtures/challenges/test-challenge-files/files/test.pdf b/tests/fixtures/challenges/test-challenge-files/files/test.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/fixtures/challenges/test-challenge-files/files/test.png b/tests/fixtures/challenges/test-challenge-files/files/test.png new file mode 100644 index 0000000000000000000000000000000000000000..de4ac2ee929a8fb69e50369ff8f1eb475904de77 GIT binary patch literal 7532 zcmeHM2UL@3mkxqp14PFHDxgRcLPCcK0i=g29ccqez(^p0By>bT2Sr5bRmxzf0!mR( z5R@iOkSbjTq=WR1`^K5yU!C1OXUo~MXLmR!hwzsB-TU00nGEr8MPLvr zN@^~K%2+QoaT$b|@D+p@TufX_0xlsXEh8l@3zwFZ6&EHW#Khq;a8bC-(Pt^#5C}sb z&d7{xrlXBO6P!eBF$6oTh=&sq$cI4WJP_ck6P9et?cs!XCLuiJ`M;h(fbX1C_Wo2P-F_@Sb6r6yPJe|q5 z9#Cfz50LbuC>1OT?TRCkaRg^>nrK@)0);Hk&;RYzA1Yzc-yTDxxZ=M)1cQcQ@mLxz zB$%j(=nqF_kd!s_O=u}nICDSYG}j_e8vss z7L|k1G{fV*yZUwIM`^Hs<(XfPeP`l-(t0H_*%fE^4FPA8i0#)E3Jo?9;8B<@hK2$B z2MqqWg;2tfzEhlLi6PeIrxkz#ZG&j*O2Qg>60!3923Qh-;)=$8@A^LlD!XEB$yf}M z#ycon1_}oN{j~MXdvbqnsCtk!F-R#}w787Dlr7X&R7wgeA&!xP+5yHw#U$V|(r5{B zv*ims7!fGw%QUl)V&6{wbM2qU z{|z_)2-iPM{SnsxCPx#$3n!YFzs4!ezhDFSSNsS6f;EH^g?yRdiqyf?1Q1AG+=h;} zUbb?y)Bo7{$-4hK7eT7{fr#H!^spuVg@WKP;wxPUu0{j`9;s~W><0AvdHLT{@>}Bh z3Ngq$-%%I#Eps7P+?S*wkYk@URg{c85~n*~GFNgRtbEkA?T-BIz5mVWy$g-;aqEz4 z8QV|kA=gO1u|Vkl{``%>{}v8_O8@1{^nj;+R3}&6{9IJ?ZfS<5BS$PRx->==X;krM z5Z(J7ENeT|3I)k{gF5zv@3D^5g6$OUDxDE{AC3 z@R3n!jCJ_N@|0GBl+y$I=J?Pg5&hpoLJnx6P~l5UoVaf}8Cl&KYNGj5b73 zuWoO&J)=0{Ix@rF;ic5|j`8udTIu`vlr7B;^c8sZJ#mUa`fkWQ)hWKxd6u8QuEVps zrbg4uEOua9FG)_YF&Y}toFLsu)rvuNW|$Y+x8>U?u%CB(ScLCso9!+4S`IJwUhN&Q zQ5}18f3(}iulPY%I}4|rKz`W*Dz27DB1JSr3EjSX_pSu~oqlf-j>p#4_R!Ize9q3! zSdR1O&Y|pEl5#s7UR~c?fT<662xUDXr7cAmw48ML)RGJf3kyRP-;Rim<`Xh`crVxD zDeB6VD_I+B>+6~-D%TI3LKJC#VSQQ3HZGExnW-^5R6#H=fA#9s7d+|9j&J*zk&zL` zv|@T1e=8&;GB!5WVB36WrX$PHb+SWneW_g!TX~Icq2kN7-Y$Cjs4;zg{a<@pre6j! zMRy2>vQ;Fk&JD$smC4mNHAS{(=xJG6Ci*PY^B+2RkXzio>0W&V|Lu&73@h}pqesuR z%)Jidso^x;j_cjr+|+P$d*ZY7R=vP=6g8@#Y;T_~Z1ci08fGIR@4XV4nJKV$?_R-n zpZ7i`K`gnv{QOz~JS^~5gY8ZcKwIm%+YDy7_6}n+%el7pcI?K|#32rjbKp?nLuXgl z0xDO^ZPMrWN}lVZG1!SVDmJNccHlX1kx0SSZLdIkCI_VyWs9t=thVRw1m&SE!q5w7 zRS;{8q+{1{DZY-ANFOr*(g*+|x}&kaUT~S}B!2enSq%@563a4=w~s_gBoemzCL2xt zs@m(rY~!s4SFgSZVHa21*_eV=)rklTM`}j!)z4LYab9~Ta34Im{{73q(PY1!#}5YI zrl~`-7K@6CB-kY`-|K%yX^foD&`IFiT$vHa;?3?V21S2rPT;?F>sI=m~T8T@n-)E?UA5 zSKm6^ujT&e-8-{(82eC(OI2Icvie=lD0(IqGn2Ws`Qh-$NQ}9BY@lM0iGiWv=+^pT zbl28=9Tzk=h~KKzRrS!JL)!W1(N`rxlmo` zsR&MaFM(wR;(Nt7X`2A_+F`&-MabE?D6l_+gmdOM>{X7-QifSMy~HaCSo*57zi#L z3x9G=vbU$uffvZhYUv|XKyysG$*lYcjlNr-Ey#x0$Hy9iBn%BGSd3Fn@&MtAr)wt-Ymq!qnu}1dWJIDA)5Q! z>#Amqkxrc0XdHTZsyjyaK0H(UHooE`kZY2A>2bbsmVqixn8s30z|zM(c+^bA`lkek(4NEN*{gE% zK7i(iv!v|}%3=G3vqH0-F-2X!pKbA0E1TSakybiM38U%kaz*eeq;bTZ$6BtnUUnt8Z*RFfkg>>@nfN-+*T{eEc;WYHZ3izRh zQB?O;QgWcYBiD2)DaB%0pC>j@Yqk{yN|sUXC1X$OBj6gFg~df)DJhe(vNG-1Q>dwx znZ5?!htE(SGab_Uc+Q-;0202|zzYB*-y24Ip4QYOz)|c{_*h|!Crx=f7EkbDH*fA) z-`Sj_+SLRzwy;nsAmxlAv-h8w8AbVXf^3|cni`tKDEx}o{qop=`%tkXb%W=;`)uUS z_NEXlB+w<4OChg6+QI&`Yn! z-=;M#SuU-uc}(;@&Bp^%vrY4zS7jcx0DK}hW6OD`cvv~*h*^c?GMPPOnd&H8 z6kwFiBzf;>M5`(%@;w^FG`2Tf^7Lt?S5;M`8Oc|@X04~;CS-Ea^kBI~mR_mr=yY#L ztu+H8kkHVP zQc?zJ3Jh zcA5J6`nEy`z4i66KX|Z+xDhIsHk(G zmWQaiwDtz<-XEDPX`L9suZNl&EceWgkC<$u-li^xM?~}#LUe-^gStR#!4CpjWRdC~ z7L=0!3XG7sxjAFg0X47f#rdK=DI)6b_tb>P#$Mb4YU^75! z>5gA-v7^J}+O>;&$YsQkeak@ehD2G?f%qdv%ELK2W474f8qm59o5+;O^vx~ z?ES=qAPBl+3C(;%zN`1G(4Z$oX={h5sHt(to&P1nWw>?$RHnw8Y&^m_^}OyS`*xjT zxtL|Z>p7MC^rSrV{O8a2fImz)gZaM6^34l2N(m3Kg-F#%Yk602P&M=a-i$8dfr}9FbCQPfx*O zn1H~eDammV1tvyA2lnsZe_KA!i?tjC5NhUwS3p33u=(+fjps&YW@aCu$?E58Kh!7! z+l_NfjIa2CK(V`Dxx%`fu zk@|=l&I+IPf;Jg2B^X>`?Z(vjE+HtB0;`(Z+QKVqTz*>@yCy1cJTg3a@(d{2E7Lt` z{V)j$qy7P{onp|o=e2pbxVVHzMJ-p$F9LEjfGinROB+fIyi53mc3DxB(r>iTttwDjG_6jQ2Bi`cvn&M#kycdY=^kevI_9G_0Sz#aF+Pde-fa;C6# znHh()voH>a6Z2ld*Ecj6aL^_dfJ6Mlk`e3TE5SESko&zL;8Tys{R#~YCkT>3`%*b}?d(yzVH#jsj zdVKtHggt|4r9AmE)v-IzATP1Fq6<{=Fh*t$vrC6VZ3}yZWwltDnI+2^my`kONF=f? zO`TQCab=>tuFZ=mEHs-|F<*Nkz5Ci#T1Wc%{mbYsZ!gh9OZGMFUPR&}&>Xy6LbUGp zXPF7%kZdU6M^k2cjRE2qO{ff0j$23q+%|xhlES*{R4Zikt5jgSkM4OAm`#=bs_t~@KD z?akE~P{NLj@g)LvojUWZEZpV>oz}8f@7+7tQ|uUHly5!I>hb~ca347=Xb8q7??sNQ z&9p2nsIIL=W$50owlAzNaTz`ke=2b=XoltYEdzYBfJJe&3m_3?8D^j^TnJb=TG)Eq zc>l19N`HSpra4{`sLne$IM}cq7#N6MogG;Clt4ftrR75wk3;%C zvn(Mh+1c4W@cXiZB7}g>D1u$wUIU~bK%RGb(#GSw0s^nYZ@AnAc{NU!l>Q(+y#b6n zsO1k5GBReg$pTC(ZI97){ML($ixK|xO!1OdGB8@ZL*up{eXa7w0RwrYfUe&XCO_et8mWJ4`p8D znP^Q7#7(KcFZ=r1qmtMC|If>h|L{AVFHE$TQ!}0Z2f#mzD!KipynT4XTIqgO9EZTU z$&lj*5s(9WPC*#2vq0!CFkPcdrLVm9_x9hg{14EbT2Sr5bRmxzf0!mR( z5R@iOkSbjTq=WR1`^K5yU!C1OXUo~MXLmR!hwzsB-TU00nGEr8MPLvr zN@^~K%2+QoaT$b|@D+p@TufX_0xlsXEh8l@3zwFZ6&EHW#Khq;a8bC-(Pt^#5C}sb z&d7{xrlXBO6P!eBF$6oTh=&sq$cI4WJP_ck6P9et?cs!XCLuiJ`M;h(fbX1C_Wo2P-F_@Sb6r6yPJe|q5 z9#Cfz50LbuC>1OT?TRCkaRg^>nrK@)0);Hk&;RYzA1Yzc-yTDxxZ=M)1cQcQ@mLxz zB$%j(=nqF_kd!s_O=u}nICDSYG}j_e8vss z7L|k1G{fV*yZUwIM`^Hs<(XfPeP`l-(t0H_*%fE^4FPA8i0#)E3Jo?9;8B<@hK2$B z2MqqWg;2tfzEhlLi6PeIrxkz#ZG&j*O2Qg>60!3923Qh-;)=$8@A^LlD!XEB$yf}M z#ycon1_}oN{j~MXdvbqnsCtk!F-R#}w787Dlr7X&R7wgeA&!xP+5yHw#U$V|(r5{B zv*ims7!fGw%QUl)V&6{wbM2qU z{|z_)2-iPM{SnsxCPx#$3n!YFzs4!ezhDFSSNsS6f;EH^g?yRdiqyf?1Q1AG+=h;} zUbb?y)Bo7{$-4hK7eT7{fr#H!^spuVg@WKP;wxPUu0{j`9;s~W><0AvdHLT{@>}Bh z3Ngq$-%%I#Eps7P+?S*wkYk@URg{c85~n*~GFNgRtbEkA?T-BIz5mVWy$g-;aqEz4 z8QV|kA=gO1u|Vkl{``%>{}v8_O8@1{^nj;+R3}&6{9IJ?ZfS<5BS$PRx->==X;krM z5Z(J7ENeT|3I)k{gF5zv@3D^5g6$OUDxDE{AC3 z@R3n!jCJ_N@|0GBl+y$I=J?Pg5&hpoLJnx6P~l5UoVaf}8Cl&KYNGj5b73 zuWoO&J)=0{Ix@rF;ic5|j`8udTIu`vlr7B;^c8sZJ#mUa`fkWQ)hWKxd6u8QuEVps zrbg4uEOua9FG)_YF&Y}toFLsu)rvuNW|$Y+x8>U?u%CB(ScLCso9!+4S`IJwUhN&Q zQ5}18f3(}iulPY%I}4|rKz`W*Dz27DB1JSr3EjSX_pSu~oqlf-j>p#4_R!Ize9q3! zSdR1O&Y|pEl5#s7UR~c?fT<662xUDXr7cAmw48ML)RGJf3kyRP-;Rim<`Xh`crVxD zDeB6VD_I+B>+6~-D%TI3LKJC#VSQQ3HZGExnW-^5R6#H=fA#9s7d+|9j&J*zk&zL` zv|@T1e=8&;GB!5WVB36WrX$PHb+SWneW_g!TX~Icq2kN7-Y$Cjs4;zg{a<@pre6j! zMRy2>vQ;Fk&JD$smC4mNHAS{(=xJG6Ci*PY^B+2RkXzio>0W&V|Lu&73@h}pqesuR z%)Jidso^x;j_cjr+|+P$d*ZY7R=vP=6g8@#Y;T_~Z1ci08fGIR@4XV4nJKV$?_R-n zpZ7i`K`gnv{QOz~JS^~5gY8ZcKwIm%+YDy7_6}n+%el7pcI?K|#32rjbKp?nLuXgl z0xDO^ZPMrWN}lVZG1!SVDmJNccHlX1kx0SSZLdIkCI_VyWs9t=thVRw1m&SE!q5w7 zRS;{8q+{1{DZY-ANFOr*(g*+|x}&kaUT~S}B!2enSq%@563a4=w~s_gBoemzCL2xt zs@m(rY~!s4SFgSZVHa21*_eV=)rklTM`}j!)z4LYab9~Ta34Im{{73q(PY1!#}5YI zrl~`-7K@6CB-kY`-|K%yX^foD&`IFiT$vHa;?3?V21S2rPT;?F>sI=m~T8T@n-)E?UA5 zSKm6^ujT&e-8-{(82eC(OI2Icvie=lD0(IqGn2Ws`Qh-$NQ}9BY@lM0iGiWv=+^pT zbl28=9Tzk=h~KKzRrS!JL)!W1(N`rxlmo` zsR&MaFM(wR;(Nt7X`2A_+F`&-MabE?D6l_+gmdOM>{X7-QifSMy~HaCSo*57zi#L z3x9G=vbU$uffvZhYUv|XKyysG$*lYcjlNr-Ey#x0$Hy9iBn%BGSd3Fn@&MtAr)wt-Ymq!qnu}1dWJIDA)5Q! z>#Amqkxrc0XdHTZsyjyaK0H(UHooE`kZY2A>2bbsmVqixn8s30z|zM(c+^bA`lkek(4NEN*{gE% zK7i(iv!v|}%3=G3vqH0-F-2X!pKbA0E1TSakybiM38U%kaz*eeq;bTZ$6BtnUUnt8Z*RFfkg>>@nfN-+*T{eEc;WYHZ3izRh zQB?O;QgWcYBiD2)DaB%0pC>j@Yqk{yN|sUXC1X$OBj6gFg~df)DJhe(vNG-1Q>dwx znZ5?!htE(SGab_Uc+Q-;0202|zzYB*-y24Ip4QYOz)|c{_*h|!Crx=f7EkbDH*fA) z-`Sj_+SLRzwy;nsAmxlAv-h8w8AbVXf^3|cni`tKDEx}o{qop=`%tkXb%W=;`)uUS z_NEXlB+w<4OChg6+QI&`Yn! z-=;M#SuU-uc}(;@&Bp^%vrY4zS7jcx0DK}hW6OD`cvv~*h*^c?GMPPOnd&H8 z6kwFiBzf;>M5`(%@;w^FG`2Tf^7Lt?S5;M`8Oc|@X04~;CS-Ea^kBI~mR_mr=yY#L ztu+H8kkHVP zQc?zJ3Jh zcA5J6`nEy`z4i66KX|Z+xDhIsHk(G zmWQaiwDtz<-XEDPX`L9suZNl&EceWgkC<$u-li^xM?~}#LUe-^gStR#!4CpjWRdC~ z7L=0!3XG7sxjAFg0X47f#rdK=DI)6b_tb>P#$Mb4YU^75! z>5gA-v7^J}+O>;&$YsQkeak@ehD2G?f%qdv%ELK2W474f8qm59o5+;O^vx~ z?ES=qAPBl+3C(;%zN`1G(4Z$oX={h5sHt(to&P1nWw>?$RHnw8Y&^m_^}OyS`*xjT zxtL|Z>p7MC^rSrV{O8a2fImz)gZaM6^34l2N(m3Kg-F#%Yk602P&M=a-i$8dfr}9FbCQPfx*O zn1H~eDammV1tvyA2lnsZe_KA!i?tjC5NhUwS3p33u=(+fjps&YW@aCu$?E58Kh!7! z+l_NfjIa2CK(V`Dxx%`fu zk@|=l&I+IPf;Jg2B^X>`?Z(vjE+HtB0;`(Z+QKVqTz*>@yCy1cJTg3a@(d{2E7Lt` z{V)j$qy7P{onp|o=e2pbxVVHzMJ-p$F9LEjfGinROB+fIyi53mc3DxB(r>iTttwDjG_6jQ2Bi`cvn&M#kycdY=^kevI_9G_0Sz#aF+Pde-fa;C6# znHh()voH>a6Z2ld*Ecj6aL^_dfJ6Mlk`e3TE5SESko&zL;8Tys{R#~YCkT>3`%*b}?d(yzVH#jsj zdVKtHggt|4r9AmE)v-IzATP1Fq6<{=Fh*t$vrC6VZ3}yZWwltDnI+2^my`kONF=f? zO`TQCab=>tuFZ=mEHs-|F<*Nkz5Ci#T1Wc%{mbYsZ!gh9OZGMFUPR&}&>Xy6LbUGp zXPF7%kZdU6M^k2cjRE2qO{ff0j$23q+%|xhlES*{R4Zikt5jgSkM4OAm`#=bs_t~@KD z?akE~P{NLj@g)LvojUWZEZpV>oz}8f@7+7tQ|uUHly5!I>hb~ca347=Xb8q7??sNQ z&9p2nsIIL=W$50owlAzNaTz`ke=2b=XoltYEdzYBfJJe&3m_3?8D^j^TnJb=TG)Eq zc>l19N`HSpra4{`sLne$IM}cq7#N6MogG;Clt4ftrR75wk3;%C zvn(Mh+1c4W@cXiZB7}g>D1u$wUIU~bK%RGb(#GSw0s^nYZ@AnAc{NU!l>Q(+y#b6n zsO1k5GBReg$pTC(ZI97){ML($ixK|xO!1OdGB8@ZL*up{eXa7w0RwrYfUe&XCO_et8mWJ4`p8D znP^Q7#7(KcFZ=r1qmtMC|If>h|L{AVFHE$TQ!}0Z2f#mzD!KipynT4XTIqgO9EZTU z$&lj*5s(9WPC*#2vq0!CFkPcdrLVm9_x9hg{14E