From 58bc4e7767e9d4235a5379f0aecf2a5a84112aff Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Sat, 29 Nov 2025 00:26:44 +0100 Subject: [PATCH 01/26] Start on transfer of cli --- .gitignore | 216 +++ README.md | 22 + src/commands/challenge_creator.py | 253 ++++ src/commands/page.py | 186 +++ src/commands/pipeline.py | 148 ++ src/commands/slugify.py | 65 + src/commands/template_renderer.py | 391 ++++++ src/ctf.py | 67 + src/demo.py | 200 +++ src/library/config.py | 26 + src/library/data.py | 709 ++++++++++ src/library/generator.py | 312 +++++ src/library/utils.py | 86 ++ src/models/__init__.py | 0 src/models/challenge.py | 307 +++++ src/models/page.py | 80 ++ src/requirements.txt | 2 + src/test.py | 11 + .../data/full-example-multi-flag-object.json | 39 + .../data/full-example-multi-flag-object.yml | 27 + src/tests/data/full-example-multi-flag.json | 34 + src/tests/data/full-example-multi-flag.yml | 26 + src/tests/data/full-example.json | 37 + src/tests/data/full-example.yaml | 29 + src/tests/data/full-example.yml | 30 + src/tests/data/minimal-example.yml | 10 + src/tests/library/dataTest.py | 1188 +++++++++++++++++ 27 files changed, 4501 insertions(+) create mode 100644 .gitignore create mode 100644 src/commands/challenge_creator.py create mode 100644 src/commands/page.py create mode 100644 src/commands/pipeline.py create mode 100644 src/commands/slugify.py create mode 100644 src/commands/template_renderer.py create mode 100644 src/ctf.py create mode 100644 src/demo.py create mode 100644 src/library/config.py create mode 100644 src/library/data.py create mode 100644 src/library/generator.py create mode 100644 src/library/utils.py create mode 100644 src/models/__init__.py create mode 100644 src/models/challenge.py create mode 100644 src/models/page.py create mode 100644 src/requirements.txt create mode 100644 src/test.py create mode 100644 src/tests/data/full-example-multi-flag-object.json create mode 100644 src/tests/data/full-example-multi-flag-object.yml create mode 100644 src/tests/data/full-example-multi-flag.json create mode 100644 src/tests/data/full-example-multi-flag.yml create mode 100644 src/tests/data/full-example.json create mode 100644 src/tests/data/full-example.yaml create mode 100644 src/tests/data/full-example.yml create mode 100644 src/tests/data/minimal-example.yml create mode 100644 src/tests/library/dataTest.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64d49ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,216 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file diff --git a/README.md b/README.md index 04aae2f..4b69d27 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,28 @@ Challenge Toolkit for CTF Pilot. Allows for bootstrapping challenges and pipeline actions on challenges. +### Code gen + +```sh +datamodel-codegen \ + --url https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json \ + --output ./models/challenge.py \ + --use-annotated \ + --use-generic-container-types \ + --use-field-description \ + --output-model-type pydantic_v2.BaseModel \ + --set-default-enum-member + +datamodel-codegen \ + --url https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json \ + --output ./models/page.py \ + --use-annotated \ + --use-generic-container-types \ + --use-field-description \ + --output-model-type pydantic_v2.BaseModel \ + --set-default-enum-member +``` + ## Contributing We welcome contributions of all kinds, from **code** and **documentation** to **bug reports** and **feedback**! diff --git a/src/commands/challenge_creator.py b/src/commands/challenge_creator.py new file mode 100644 index 0000000..a9d380d --- /dev/null +++ b/src/commands/challenge_creator.py @@ -0,0 +1,253 @@ +''' +Template Generator for CTF Challenges + +Prompts the user for inputs and generates a template for a CTF challenge. +''' + +import sys +import argparse + +from library.config import CHALL_TYPES, DIFFICULTIES, FLAG_FORMAT, INSTANCED_TYPES, CATEGORIES +from library.utils import Utils +from library.data import Challenge, DockerfileLocation +from library.generator import Generator as OSGenerator + +class Args: + args = None + subcommand = False + + def __init__(self, parent_parser = None): + if parent_parser: + self.subcommand = True + self.parser = parent_parser.add_parser("create", help="Template Generator for CTF Challenges") + else: + self.parser = argparse.ArgumentParser(description="Template Generator for CTF Challenges") + + self.parser.add_argument("--no-prompts", help="Skip prompts and use default values", action="store_true") + self.parser.add_argument("--name", help="Name of the challenge") + self.parser.add_argument("--slug", help="Slug of the challenge") + self.parser.add_argument("--author", help="Author of the challenge") + self.parser.add_argument("--category", help="Category of the challenge") + self.parser.add_argument("--difficulty", help="Difficulty of the challenge") + self.parser.add_argument("--type", help="Type of the challenge") + self.parser.add_argument("--instanced-type", help="Type of instanced challenge", default="none") + self.parser.add_argument("--flag", help="Flag for the challenge", type=str) + self.parser.add_argument("--points", help="Points for the challenge", type=int, default=1000) + self.parser.add_argument("--min-points", help="Minimum points for the challenge", type=int, default=100) + self.parser.add_argument("--description-location", help="Location of the description file", default="description.md") + self.parser.add_argument("--dockerfile-location", help="Location of the Dockerfile", default="src/Dockerfile") + self.parser.add_argument("--dockerfile-context", help="Context of the Dockerfile", default="src/") + self.parser.add_argument("--dockerfile-identifier", help="Identifier of the Dockerfile", default=None) + self.parser.add_argument("--handout_location", help="Location of the handout", default="handout") + + def parse(self): + if self.subcommand: + self.args = self.parser.parse_args(sys.argv[2:]) + else: + self.args = self.parser.parse_args() + + def prompt(self, challenge: Challenge): + args = self.args + + if args is None: + # Convert to object if args is None + args = self.args = self.parser.parse_args() + + # Ensure args is not None before accessing its attributes + if args.name is None: + while True: + try: + challenge.set_name(input("Name of the challenge: ")) + break + except ValueError: + pass + + if args.slug == None: + while True: + try: + challenge.set_slug(input(f"Slug of the challenge ({Utils.slugify(challenge.name)}): ") or Utils.slugify(challenge.name) or "challenge") + break + except ValueError: + pass + + if args.author == None: + while True: + try: + challenge.set_author(input("Author of the challenge: ")) + break + except ValueError: + pass + + if args.category == None: + while True: + try: + challenge.set_category(input(f"Category of the challenge ({', '.join(CATEGORIES)}): ").lower()) + break + except ValueError: + pass + + if args.difficulty == None: + while True: + try: + challenge.set_difficulty(input(f"Difficulty of the challenge ({', '.join(DIFFICULTIES)}): ").lower()) + break + except ValueError: + pass + + prompted_type = None + if args.type == None: + while True: + try: + prompted_type = input(f"Type of the challenge ({', '.join(CHALL_TYPES)}): ").lower() + challenge.set_type(prompted_type) + break + except ValueError: + pass + + if args.flag == None: + while True: + try: + challenge.set_flag(input(f"Flag for the challenge ({FLAG_FORMAT}): ")) + break + except ValueError: + pass + + if args.points == None: + while True: + try: + challenge.set_points(int(input("Points for the challenge (1000): ") or 1000)) + break + except ValueError: + pass + + if args.min_points == None: + while True: + try: + challenge.set_min_points(int(input("Minimum points for the challenge (100): ") or 100 )) + break + except ValueError: + pass + + if (args.type == "instanced" or prompted_type == "instanced") and args.instanced_type == "none": + while True: + try: + challenge.set_instanced_type(input(f"Type of instanced challenge ({', '.join(INSTANCED_TYPES)}): ").lower()) + break + except ValueError: + pass + else: + challenge.set_instanced_type("none") + + if args.description_location == "description.md": + while True: + try: + challenge.set_description_location(input("Location of the description file (description.md): ") or "description.md") + break + except ValueError: + pass + else: + challenge.set_description_location(args.description_location) + + if args.dockerfile_location == None or args.dockerfile_location == "src/Dockerfile": + contains_docker = input("Does the challenge contain a Dockerfile? (y/N): ").lower() == "y" + if contains_docker: + while True: + try: + dockerfile_location = input("Location of the Dockerfile (src/Dockerfile): ") or "src/Dockerfile" + dockerfile_context = input("Context of the Dockerfile (src/): ") or "src/" + dockerfile_identifier = input("Identifier of the Dockerfile: ") or None + + challenge.add_dockerfile_location([ DockerfileLocation(dockerfile_location, dockerfile_context, dockerfile_identifier) ]) + break + except ValueError: + pass + + if args.handout_location == "handout": + contains_handout = input("What is the location of the handout for handing out with the challenge? (handout): ") or "handout" + if contains_handout: + challenge.set_handout_dir(contains_handout) + else: + challenge.set_handout_dir(args.handout_location) + + return challenge + +class Generator: + def __init__(self, challenge: Challenge): + self.challenge = challenge + self.path = Utils.get_challenge_dir(challenge.category, challenge.slug) + self.generator = OSGenerator(challenge) + + def generate(self): + self.generator.build() + +class ChallengeCreator: + args = None + parent_parser = None + + def __init__(self, parent_parser = None): + self.parent_parser = parent_parser + + def register_subcommand(self): + self.args = Args(self.parent_parser) + + def run(self): + if not self.args: + arguments = Args(self.parent_parser) + arguments.parse() + self.args = arguments + else: + self.args.parse() + self.args = self.args + + arguments = self.args + args = self.args.args + + if not args: + print("Error parsing arguments. Please run with --help to see available options.") + sys.exit(1) + + if args.slug is None: + args.slug = Utils.slugify(args.name) if args.name else "challenge" + + challenge = None + challenge = challenge = Challenge( + name = args.name, + slug = args.slug, + author = args.author, + category = args.category, + difficulty = args.difficulty, + type = args.type, + instanced_type = args.instanced_type or "none", + flag = args.flag, + points = args.points or 1000, + min_points = args.min_points or 100, + description_location = args.description_location, + handout_dir = args.handout_location + ) + if args.no_prompts and args.type != "static": + try: + if args.dockerfile_location: + challenge.add_dockerfile_location([ DockerfileLocation(args.dockerfile_location, args.dockerfile_context, args.dockerfile_identifier) ]) + except ValueError: + sys.exit(1) + + if args.no_prompts == False: + arguments.prompt(challenge) + + print("\nInformation filled out.") + + print("\nInformation for the challenge:") + print(challenge) + + print("\nIs the information correct?") + if (input("Y/n: ") or "y").lower() != "y": + print("Exiting...") + sys.exit(1) + + generator = Generator(challenge) + generator.generate() + + +if __name__ == '__main__': + ChallengeCreator().run() + diff --git a/src/commands/page.py b/src/commands/page.py new file mode 100644 index 0000000..d649941 --- /dev/null +++ b/src/commands/page.py @@ -0,0 +1,186 @@ +import os +import sys +import argparse + +from datetime import datetime + +from library.utils import Utils +from library.data import Page +from library.generator import Generator + +class Args: + args = None + page: Page + subcommand = False + repo: str + + def __init__(self, parent_parser = None): + if parent_parser: + self.subcommand = True + self.parser = parent_parser.add_parser("page", help="Render template for CTFd pages") + self.parser = parent_parser.add_parser("repo", help="GitHub repository for CTFd pages in the format 'owner/repo'") + else: + self.parser = argparse.ArgumentParser(description="Render template for CTFd pages") + + self.parser.add_argument("page", help="Page to render (directory for page - 'web/example')") + self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY ", "ctfpilot/example")) + + def parse(self): + if self.subcommand: + self.args = self.parser.parse_args(sys.argv[2:]) + else: + self.args = self.parser.parse_args() + + # Parse page from page argument + page_path = Utils.get_page_dir(self.args.page) + if not page_path.exists() or not page_path.is_dir(): + print(f"Page {self.args.page} does not exist") + sys.exit(1) + + page = Page.load_dir(page_path) + + if not page: + print(f"Page {self.args.page} is not a valid page") + sys.exit(1) + + self.page = page + self.repo = self.args.repo or os.getenv("GITHUB_REPOSITORY", "ctfpilot/example") + + def __getattr__(self, name): + return getattr(self.args, name) + +class PageRender: + ''' + Generate configmap for k8s, which contains the page json file + ''' + configmap_template = "page-configmap.yml" + page: Page + + def __init__(self, page: Page): + self.page = page + self.generator = Generator(page=page) + + def run(self): + if not self.page: + print("No page specified") + sys.exit(1) + + @staticmethod + def replace_templated(key: str, value: str, content: str): + content = content.replace("{{ " + key + " }}", value) + content = content.replace("{{" + key + "}}", value) + content = content.replace("{ { " + key + " } }", value) + content = content.replace("{ {" + key + "} }", value) + return content + + def get_template_content(self): + template_source = self.page.str_json("https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json") + + # Iterate over each line in the source, and indent it + template_source_indented = "".join([" " + line + "\n" for line in template_source.splitlines()]) + + return template_source_indented + + def get_content(self): + if not self.page: + print("No page specified") + sys.exit(1) + + # Get the content of the page + content = self.page.content + + # Check if file exists + page_path = Utils.get_page_dir(self.page.slug) + content_path = page_path.joinpath(content) + + print(f"Rendering content from {content_path}") + + if not content_path.exists(): + print(f"Content file {content} does not exist in page {self.page.slug}") + sys.exit(1) + with open(content_path, "r") as f: + rendered_content = f.read() + + # Iterate over each line in the source, and indent it + rendered_content_indented = "".join([" " + line + "\n" for line in rendered_content.splitlines()]) + + return rendered_content_indented + + def render(self, args: Args): + if not os.path.exists(Utils.get_template_dir()) or not os.path.isdir(Utils.get_template_dir()) or not os.path.exists(os.path.join(Utils.get_template_dir(), self.configmap_template)): + print("Configmap template source file does not exist. Critical error.") + sys.exit(1) + + # Increment version + version = self.page.get_version() + print(f"Current version: {version}") + print("Incrementing version...") + version += 1 + self.page.save_version(version) + print(f"New version: {version}") + + # Get template content + template = os.path.join(Utils.get_template_dir(), self.configmap_template) + template_content = "" + with open(template, "r") as f: + template_content = f.read() + + # Insert template content + template_json = self.get_template_content() + output_content_initial = template_content.replace(" %%PAGE%%", template_json) + + content = self.get_content() + output_content = output_content_initial.replace(" %%CONTENT%%", content) + + # Template values in configmap + output_content = self.replace_templated("PAGE_SLUG", self.page.slug, output_content) + output_content = self.replace_templated("PAGE_NAME", self.page.slug, output_content) + output_content = self.replace_templated("PAGE_PATH", Utils.get_page_dir_str(self.page.slug), output_content) + output_content = self.replace_templated("PAGE_REPO", args.repo, output_content) + output_content = self.replace_templated("PAGE_VERSION", str(args.page.get_version()), output_content) + output_content = self.replace_templated("PAGE_ENABLED", str(args.page.enabled).lower(), output_content) + + # Insert the current date, for knowing when the challenge was last updated + now = datetime.now() + current_date = now.strftime("%Y-%m-%d %H:%M:%S") + output_content = self.replace_templated("CURRENT_DATE", current_date, output_content) + + # Write the output to a file + output_file = os.path.join(Utils.get_k8s_page_dir(args.page.slug), f"page.yml") + if not os.path.exists(os.path.dirname(output_file)): + os.makedirs(os.path.dirname(output_file)) + + with open(output_file, "w") as f: + f.write(output_content) + + print(f"Configmap generated at {output_file}") + +class PageCommand: + args = None + parent_parser = None + + def __init__(self, parent_parser = None): + self.parent_parser = parent_parser + + def register_subcommand(self): + self.args = Args(self.parent_parser) + + def run(self): + if not self.args: + arguments = Args(self.parent_parser) + arguments.parse() + self.args = arguments + else: + self.args.parse() + self.args = self.args + + args = self.args + + if not args or not args.page: + print("No page specified") + return + + PageRender(args.page).render(args) + +if __name__ == "__main__": + PageCommand().run() \ No newline at end of file diff --git a/src/commands/pipeline.py b/src/commands/pipeline.py new file mode 100644 index 0000000..8ade0d8 --- /dev/null +++ b/src/commands/pipeline.py @@ -0,0 +1,148 @@ +import os +import sys +import argparse +import subprocess + +from library.utils import Utils +from library.data import Challenge, DockerfileLocation + +class Args: + args = None + subcommand = False + + def __init__(self, parent_parser = None): + if parent_parser: + self.subcommand = True + self.parser = parent_parser.add_parser("pipeline", help="Pipeline for CTF challenges") + else: + self.parser = argparse.ArgumentParser(description="Pipeline for CTF challenges") + + self.parser.add_argument("challenge", help="Challenge to run (directory for challenge - 'web/example')") + self.parser.add_argument("registry", help="Registry to push the Docker image to") + self.parser.add_argument("image_prefix", help="Prefix for the Docker image") + self.parser.add_argument("--image_suffix", help="Suffix for the Docker image", default="") + + def parse(self): + if self.subcommand: + self.args = self.parser.parse_args(sys.argv[2:]) + else: + self.args = self.parser.parse_args() + + def __getattr__(self, name): + return getattr(self.args, name) + +class Docker: + def __init__(self, registry: str, image_prefix: str, image_suffix: str): + self.registry = registry + self.image_prefix = image_prefix + self.image_suffix = image_suffix + + @staticmethod + def build(registry: str, image_prefix: str, image_suffix: str, challenge: Challenge, dockerfile_location: DockerfileLocation): + image_full = f"{registry}/{image_prefix}-{Utils.slugify(challenge.category)}-{challenge.slug}".lower() + + if dockerfile_location.identifier and dockerfile_location.identifier.lower() not in ["none", "null", ""]: + image_full += f"-{dockerfile_location.identifier}" + if image_suffix and image_suffix.lower() not in ["none", "null", ""]: + image_full += f"-{image_suffix}" + + image_full = image_full.lower() + + print(f"Building Docker image \"{image_full}\"...") + + try: + command = f"docker build -t {image_full}:latest -t {image_full}:{challenge.get_version()} -f {Utils.get_challenge_dir(challenge.category, challenge.slug)}/{dockerfile_location.location} {Utils.get_challenge_dir(challenge.category, challenge.slug)}/{dockerfile_location.context}" + build_proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if build_proc.stdout is not None: + for line in build_proc.stdout: + print(line, end="") + build_proc.wait() + if build_proc.returncode != 0: + raise subprocess.CalledProcessError(build_proc.returncode, command) + + command = f"docker push {image_full} --all-tags" + push_proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if push_proc.stdout is not None: + for line in push_proc.stdout: + print(line, end="") + push_proc.wait() + if push_proc.returncode != 0: + raise subprocess.CalledProcessError(push_proc.returncode, command) + except subprocess.CalledProcessError as e: + print(f"Error: Command failed with exit code {e.returncode}: {e.cmd}", file=sys.stderr) + raise e + +class DockerBuild: + args = None + parent_parser = None + + def __init__(self, parent_parser = None): + self.parent_parser = parent_parser + + def register_subcommand(self): + self.args = Args(self.parent_parser) + + def run(self): + if not self.args: + arguments = Args(self.parent_parser) + arguments.parse() + self.args = arguments + else: + self.args.parse() + self.args = self.args + + args = self.args.args + + if not args: + print("No arguments provided") + sys.exit(1) + + challenge = args.challenge + + if not "/" in challenge: + print(f"Challenge {challenge} must be in the format 'category/name'") + exit(1) + + challenge_path = Utils.get_challenges_dir().joinpath(challenge) + if not challenge_path.exists(): + print(f"Challenge {challenge} does not exist") + sys.exit(1) + + if not challenge_path.is_dir(): + print(f"Challenge {challenge} is not a directory") + sys.exit(1) + + print(f"Running pipeline for challenge \"{challenge}\"") + + print("Loading challenge data...") + challenge = Challenge.load_dir(challenge_path) + if not challenge: + print("Failed to load challenge data") + sys.exit(1) + + print("Data loaded successfully") + print("") + print("Data:") + print(challenge) + print("") + + version = challenge.get_version() + print(f"Current version: {version}") + print("Incrementing version...") + version += 1 + challenge.save_version(version) + print(f"New version: {version}") + print("") + print("") + + print("Starting docker process...") + docker = Docker(args.registry, args.image_prefix, args.image_suffix) + for dockerfile_location in challenge.dockerfile_locations: + print(f"Building Docker image for {dockerfile_location.identifier or 'default'}...") + Docker.build(docker.registry, docker.image_prefix, docker.image_suffix, challenge, dockerfile_location) + + print("Docker process complete") + +if __name__ == "__main__": + DockerBuild().run() + diff --git a/src/commands/slugify.py b/src/commands/slugify.py new file mode 100644 index 0000000..861fd5f --- /dev/null +++ b/src/commands/slugify.py @@ -0,0 +1,65 @@ +import os +import sys +import argparse + +from datetime import datetime + +from library.utils import Utils + +class Args: + args = None + slug: str = None + subcommand = False + + def __init__(self, parent_parser = None): + if parent_parser: + self.subcommand = True + self.parser = parent_parser.add_parser("slugify", help="Slugify a string for use in challenge slug") + else: + self.parser = argparse.ArgumentParser(description="Slugify a string for use in challenge slug") + + self.parser.add_argument("--name", help="Name to slugify", required=True) + + def parse(self): + if self.subcommand: + self.args = self.parser.parse_args(sys.argv[2:]) + else: + self.args = self.parser.parse_args() + + if not self.args.name: + print("Name is required") + sys.exit(1) + + self.slug = self.args.name + +class Slugify: + @staticmethod + def run(name: str) -> str: + """ + Slugify a string for use in challenge slug. + """ + return Utils.slugify(name) + +class SlugifyCommand: + args = None + parent_parser = None + + def __init__(self, parent_parser = None): + self.parent_parser = parent_parser + + def register_subcommand(self): + self.args = Args(self.parent_parser) + + def run(self): + if not self.args: + arguments = Args(self.parent_parser) + arguments.parse() + self.args = arguments + else: + self.args.parse() + self.args = self.args + + args = self.args + + print(Slugify.run(args.slug)) + diff --git a/src/commands/template_renderer.py b/src/commands/template_renderer.py new file mode 100644 index 0000000..5fea936 --- /dev/null +++ b/src/commands/template_renderer.py @@ -0,0 +1,391 @@ +import os +import sys +import argparse +import tempfile +import shutil + +from datetime import datetime + +from library.utils import Utils +from library.data import Challenge +from library.generator import Generator + +class Args: + args = None + challenge: Challenge + subcommand = False + expires: int = 3600 + available: int = 0 + repo: str + + def __init__(self, parent_parser = None): + if parent_parser: + self.subcommand = True + self.parser = parent_parser.add_parser("template", help="Render template for K8s challenge") + else: + self.parser = argparse.ArgumentParser(description="Render template for K8s challenge") + + self.parser.add_argument("renderer", help="Renderer to use for the challenge", choices=["k8s", "configmap", "clean", "handout"]) + self.parser.add_argument("challenge", help="Challenge to run (directory for challenge - 'web/example')") + self.parser.add_argument("--expires", help="Time until challenge expires", type=int, default=3600) + self.parser.add_argument("--available", help="Time until challenge is available", type=int, default=0) + self.parser.add_argument("--repo", help="GitHub repository for CTFd challenges in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY ", "ctfpilot/example")) + + def parse(self): + if self.subcommand: + self.args = self.parser.parse_args(sys.argv[2:]) + else: + self.args = self.parser.parse_args() + + # Parse challenge from challenge argument + challenge_path = Utils.get_challenges_dir().joinpath(self.args.challenge) + if not challenge_path.exists() or not challenge_path.is_dir(): + print(f"Challenge {self.args.challenge} does not exist") + sys.exit(1) + + challenge = Challenge.load_dir(challenge_path) + + if not challenge: + print(f"Challenge {self.args.challenge} is not a valid challenge") + sys.exit(1) + + self.challenge = challenge + + self.expires = self.args.expires + self.available = self.args.available + self.repo = self.args.repo or os.getenv("GITHUB_REPOSITORY", "ctfpilot/example") + + def __getattr__(self, name): + return getattr(self.args, name) + +class Clean: + def __init__(self, challenge: Challenge): + self.challenge = challenge + + def run(self): + path = Utils.get_k8s_dir(self.challenge.category, self.challenge.slug) + if not os.path.exists(path): + print(f"Challenge {self.challenge.slug} does not have a k8s directory.") + return + + # Empty the k8s directory + for root, dirs, files in os.walk(path, topdown=False): + for name in files: + file_path = os.path.join(root, name) + try: + os.remove(file_path) + print(f"Removed file: {file_path}") + except Exception as e: + print(f"Error removing file {file_path}: {e}") + for name in dirs: + dir_path = os.path.join(root, name) + try: + os.rmdir(dir_path) + print(f"Removed directory: {dir_path}") + except Exception as e: + print(f"Error removing directory {dir_path}: {e}") + + print(f"Cleaned instanced template for {self.challenge.slug}") + +class K8s: + def __init__(self, challenge: Challenge): + self.challenge = challenge + self.generator = Generator(challenge) + + @staticmethod + def replace_templated(key: str, value: str, content: str): + content = content.replace("{{ " + key + " }}", value) + content = content.replace("{{" + key + "}}", value) + content = content.replace("{ { " + key + " } }", value) + content = content.replace("{ {" + key + "} }", value) + return content + + def get_template_content(self): + template_source_path = os.path.join(Utils.get_template_dir(), "instanced-k8s-challenge.yml") + with open(template_source_path, "r") as f: + base_template_content = f.read() + + challenge_template = os.path.join(self.generator.dir_template, "k8s.yml") + challenge_template_content = "" + challenge_template_indented = "" + with open(challenge_template, "r") as f: + challenge_template_content = f.read() + challenge_template_indented = "\n".join([" " + line for line in challenge_template_content.splitlines()]) + + return base_template_content, challenge_template_content, challenge_template_indented + + def render(self, args: Args): + if not self.generator.instanced_template_source_file_exists(): + print("Instanced template source file does not exist. Critical error.") + sys.exit(1) + + if not self.generator.instanced_template_file_exists(): + print("Challenge does not have a k8s template.") + sys.exit(0) + + base_template_content, challenge_template, challenge_template_indented = self.get_template_content() + + # If instanced, it needs to utalize the base template for instanced challenges + templateing_base_template = challenge_template + if self.challenge.type == "instanced": + templateing_base_template = base_template_content + + print(f"Rendering k8s template for challenge {args.challenge.slug}...") + + output_content = templateing_base_template.replace(" %%TEMPLATE%%", challenge_template_indented) + + output_content = K8s.replace_templated("CHALLENGE_NAME", args.challenge.slug, output_content) + output_content = K8s.replace_templated("CHALLENGE_CATEGORY", args.challenge.category, output_content) + output_content = K8s.replace_templated("CHALLENGE_TYPE", args.challenge.instanced_type, output_content) + output_content = K8s.replace_templated("CHALLENGE_VERSION", str(args.challenge.get_version()), output_content) + output_content = K8s.replace_templated("CHALLENGE_EXPIRES", str(args.expires), output_content) + output_content = K8s.replace_templated("CHALLENGE_AVAILABLE_AT", str(args.available), output_content) + + # Create docker image name + docker_image = f"{args.challenge.category}-{args.challenge.slug}".lower().replace(" ", "") + output_content = K8s.replace_templated("DOCKER_IMAGE", docker_image, output_content) + + deployment_dir = Utils.get_challenge_render_dir(args.challenge.category, args.challenge.slug) + if not os.path.exists(deployment_dir): + os.makedirs(deployment_dir) + + if self.challenge.type != "instanced": + # Create helm chart template + helm_template = os.path.join(deployment_dir, "Chart.yaml") + with open(helm_template, "w") as f: + f.write("apiVersion: v2\n") + f.write(f"name: {args.challenge.slug}\n") + semver_version = f"1.{args.challenge.get_version()}.0" + f.write(f"version: {semver_version}\n") + f.write(f"description: Challenge {args.challenge.slug} in category {args.challenge.category}\n") + f.write(f"appVersion: \"{semver_version}\"\n") + f.write(f"type: application\n") + + helm_values_file = os.path.join(deployment_dir, "values.yaml") + with open(helm_values_file, "w") as f: + f.write(f"challenge:\n") + f.write(f" enabled: {str(args.challenge.enabled).lower()}\n") + f.write(f" name: {args.challenge.slug}\n") + f.write(f" category: {args.challenge.category}\n") + f.write(f" type: {args.challenge.instanced_type}\n") + f.write(f" version: {args.challenge.get_version()}\n") + f.write(f" path: {Utils.get_challenge_dir_str(args.challenge.category, args.challenge.slug)}\n") + f.write(f" dockerImage: {docker_image}\n") + f.write(f"kubectf:\n") + f.write(f" expires: {args.expires}\n") + f.write(f" availableAt: {args.available}\n") + f.write(f" host: example.com\n") + + deployment_dir = os.path.join(deployment_dir, "templates") + + output_file = os.path.join(deployment_dir, "k8s.yml") + if not os.path.exists(os.path.dirname(output_file)): + os.makedirs(os.path.dirname(output_file)) + + with open(output_file, "w") as f: + f.write(output_content) + + print(f"K8s template generated at {output_file}") + +class ConfigMap: + ''' + Generate configmap for k8s, which contains the challenge json file + ''' + configmap_template = "challenge-configmap.yml" + + def __init__(self, challenge: Challenge): + self.challenge = challenge + + def replace_templated(self, key: str, value: str, content: str): + content = content.replace("{{ " + key + " }}", value) + content = content.replace("{{" + key + "}}", value) + content = content.replace("{ { " + key + " } }", value) + content = content.replace("{ {" + key + "} }", value) + return content + + def get_template_content(self): + template_source = self.challenge.str_json("https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json") + + # Iterate over each line in the source, and indent it + template_source_indented = "".join([" " + line + "\n" for line in template_source.splitlines()]) + + return template_source_indented + + def get_description(self): + return "".join([" " + line + "\n" for line in self.challenge.get_description().splitlines()]) + + def render(self, args: Args): + if not os.path.exists(Utils.get_template_dir()) or not os.path.isdir(Utils.get_template_dir()) or not os.path.exists(os.path.join(Utils.get_template_dir(), self.configmap_template)): + print("Configmap template source file does not exist. Critical error.") + sys.exit(1) + + template = os.path.join(Utils.get_template_dir(), self.configmap_template) + template_content = "" + with open(template, "r") as f: + template_content = f.read() + + # Insert template content + template_json = self.get_template_content() + output_content = template_content.replace(" %%CONFIG%%", template_json) + output_content = output_content.replace(" %%DESCRIPTION%%", self.get_description()) + + # Template values in configmap + output_content = self.replace_templated("CHALLENGE_NAME", args.challenge.slug, output_content) + output_content = self.replace_templated("CHALLENGE_PATH", Utils.get_challenge_dir_str(self.challenge.category, self.challenge.slug), output_content) + output_content = self.replace_templated("CHALLENGE_REPO", args.repo, output_content) + output_content = self.replace_templated("CHALLENGE_CATEGORY", args.challenge.category, output_content) + output_content = self.replace_templated("CHALLENGE_TYPE", args.challenge.instanced_type, output_content) + output_content = self.replace_templated("CHALLENGE_VERSION", str(args.challenge.get_version()), output_content) + output_content = self.replace_templated("CHALLENGE_ENABLED", str(args.challenge.enabled).lower(), output_content) + output_content = self.replace_templated("HOST", "{{ .Values.kubectf.host }}", output_content) + + # Insert the current date, for knowing when the challenge was last updated + now = datetime.now() + current_date = now.strftime("%Y-%m-%d %H:%M:%S") + output_content = self.replace_templated("CURRENT_DATE", current_date, output_content) + + configmap_dir =Utils.get_configmap_dir(args.challenge.category, args.challenge.slug) + if not os.path.exists(configmap_dir): + os.makedirs(configmap_dir) + + helm_template = os.path.join(configmap_dir, "Chart.yaml") + with open(helm_template, "w") as f: + f.write("apiVersion: v2\n") + f.write(f"name: configmap-{args.challenge.slug}\n") + semver_version = f"1.{args.challenge.get_version()}.0" + f.write(f"version: {semver_version}\n") + f.write(f"description: Challenge configmap for {args.challenge.slug} in category {args.challenge.category}\n") + f.write(f"appVersion: \"{semver_version}\"\n") + f.write(f"type: application\n") + + helm_values_file = os.path.join(configmap_dir, "values.yaml") + with open(helm_values_file, "w") as f: + f.write(f"challenge:\n") + f.write(f" enabled: {str(args.challenge.enabled).lower()}\n") + f.write(f" name: {args.challenge.slug}\n") + f.write(f" category: {args.challenge.category}\n") + f.write(f" type: {args.challenge.instanced_type}\n") + f.write(f" version: {args.challenge.get_version()}\n") + f.write(f" path: {Utils.get_challenge_dir_str(args.challenge.category, args.challenge.slug)}\n") + f.write(f"kubectf:\n") + f.write(f" expires: {args.expires}\n") + f.write(f" availableAt: {args.available}\n") + f.write(f" host: example.com\n") + + configmap_dir = os.path.join(configmap_dir, "templates") + output_file = os.path.join(configmap_dir, "k8s.yml") + if not os.path.exists(os.path.dirname(output_file)): + os.makedirs(os.path.dirname(output_file)) + + with open(output_file, "w") as f: + f.write(output_content) + + print(f"Configmap generated at {output_file}") + +class HandoutRenderer: + def __init__(self, challenge: Challenge): + self.challenge = challenge + + def render(self): + print(f"Rendering handout for challenge {self.challenge.slug}...") + + # Copy the handout directory to a temporary location + challenge_path = Utils.get_challenge_dir(self.challenge.category, self.challenge.slug) + + # Check if the file directory exists + path = Utils.get_k8s_dir(self.challenge.category, self.challenge.slug) + files_path = os.path.join(path, "files") + if not os.path.exists(files_path) or not os.path.isdir(files_path): + print(f"Files directory ({files_path}) does not exist for challenge {self.challenge.slug}.") + print(f"Creating files directory for challenge {self.challenge.slug}.") + os.makedirs(files_path, exist_ok=True) + # Create a .gitkeep file to ensure the directory is tracked by git + gitkeep_path = os.path.join(files_path, ".gitkeep") + if not os.path.exists(gitkeep_path): + with open(gitkeep_path, "w") as f: + f.write("# This file is to keep the directory in git.\n") + print(f"Files directory created at {files_path}.") + else: + print(f"Files directory ({files_path}) exists for challenge {self.challenge.slug}.") + + # Check if the handout directory exists + handout_dir = self.challenge.handout_dir + handout_path = os.path.join(challenge_path, handout_dir) + if not os.path.exists(handout_path) or not os.path.isdir(handout_path): + print(f"Handout directory {handout_dir} does not exist for challenge {self.challenge.slug}.") + print("Please create the handout directory and add the necessary files, if you want to pack handout files.") + sys.exit(0) + + # Create temporary directory for handout + with tempfile.TemporaryDirectory() as temp_dir: + # Create structure of //handout + temp_handout_path = os.path.join(temp_dir, f"{self.challenge.category}_{self.challenge.slug}") + os.makedirs(temp_handout_path, exist_ok=True) + + # Copy files from the handout directory to the temporary directory + for item in os.listdir(handout_path): + source_item = os.path.join(handout_path, item) + dest_item = os.path.join(temp_handout_path, item) + + if item in ['.gitkeep', '.gitignore']: + # Skip .gitkeep and .gitignore files + continue + + if os.path.isdir(source_item): + # Copy directory + shutil.copytree(source_item, dest_item, dirs_exist_ok=True) + else: + # Copy file + shutil.copy2(source_item, dest_item) + + # If no files are present in the handout directory, do not create a zip file + if not os.listdir(temp_handout_path): + print("No files found in the handout directory. Skipping zip creation.") + return + + # Create a zip file of the handout directory + handout_zip_path = os.path.join(files_path, f"{self.challenge.category}_{self.challenge.slug}") + shutil.make_archive(handout_zip_path, 'zip', root_dir=temp_dir, base_dir=f"{self.challenge.category}_{self.challenge.slug}") + print(f"Handout files zipped to {handout_zip_path}.zip") + + print("Handout rendered successfully for challenge:", self.challenge.slug) + +class TemplateRenderer: + args = None + parent_parser = None + + def __init__(self, parent_parser = None): + self.parent_parser = parent_parser + + def register_subcommand(self): + self.args = Args(self.parent_parser) + + def run(self): + if not self.args: + arguments = Args(self.parent_parser) + arguments.parse() + self.args = arguments + else: + self.args.parse() + self.args = self.args + + args = self.args + + if args.renderer == "clean": + clean = Clean(args.challenge) + clean.run() + return + elif args.renderer == "k8s": + k8s = K8s(args.challenge) + k8s.render(args) + elif args.renderer == "configmap": + configmap = ConfigMap(args.challenge) + configmap.render(args) + elif args.renderer == "handout": + handout_renderer = HandoutRenderer(args.challenge) + handout_renderer.render() + else: + print(f"Renderer {args.renderer} not supported.") + +if __name__ == "__main__": + TemplateRenderer().run() diff --git a/src/ctf.py b/src/ctf.py new file mode 100644 index 0000000..28e2fcb --- /dev/null +++ b/src/ctf.py @@ -0,0 +1,67 @@ +import os + +import argparse + +from commands.challenge_creator import ChallengeCreator +from commands.template_renderer import TemplateRenderer +from commands.page import PageCommand +from commands.pipeline import DockerBuild +from commands.slugify import SlugifyCommand + +class Args: + command = None + parser = None + + def __init__(self): + self.parser = argparse.ArgumentParser(description="Challenge Toolkit CLI") + + def print_help(self): + if self.parser: + self.parser.print_help() + +if __name__ == "__main__": + try: + args = Args() + + if (args.parser is None): + print("Error: Parser is not initialized.") + exit(1) + + subparser = args.parser.add_subparsers(dest="command", help="Subcommand to run", title="subcommands") + + challengeCreator = ChallengeCreator(subparser) + challengeCreator.register_subcommand() + templateRenderer = TemplateRenderer(subparser) + templateRenderer.register_subcommand() + dockerBuild = DockerBuild(subparser) + dockerBuild.register_subcommand() + pageRender = PageCommand(subparser) + pageRender.register_subcommand() + slugify = SlugifyCommand(subparser) + slugify.register_subcommand() + + # Get subcommand to run + namespace = args.parser.parse_args() + command = namespace.command + + # Call the appropriate tool based on the command + if command == "create": + challengeCreator.run() + elif command == "template": + templateRenderer.run() + elif command == "pipeline": + dockerBuild.run() + elif command == "page": + pageRender.run() + elif command == "slugify": + slugify.run() + else: + args.print_help() + exit(1) + except Exception as e: + # Detect if we are running inside a Github runner + if os.getenv("GITHUB_ACTIONS"): + print(f"::error::An error occurred: {e}") + + raise e + diff --git a/src/demo.py b/src/demo.py new file mode 100644 index 0000000..54221b3 --- /dev/null +++ b/src/demo.py @@ -0,0 +1,200 @@ +# generated by datamodel-codegen: +# filename: https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json +# timestamp: 2025-11-26T22:22:46+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel, Field, conint, constr + + +class Prerequisite(BaseModel): + __root__: constr(regex=r'^[a-z0-9-]+$', min_length=1, max_length=100) = Field( + ..., + description='The prerequisites of the challenge. Must be the slugs of the challenges.', + ) + + +class Category(Enum): + web = 'web' + forensics = 'forensics' + rev = 'rev' + crypto = 'crypto' + pwn = 'pwn' + boot2root = 'boot2root' + osint = 'osint' + misc = 'misc' + blockchain = 'blockchain' + mobile = 'mobile' + test = 'test' + + +class Difficulty(Enum): + beginner = 'beginner' + easy = 'easy' + easy_medium = 'easy-medium' + medium = 'medium' + medium_hard = 'medium-hard' + hard = 'hard' + very_hard = 'very-hard' + insane = 'insane' + + +class Type(Enum): + static = 'static' + shared = 'shared' + instanced = 'instanced' + + +class InstancedType(Enum): + web = 'web' + tcp = 'tcp' + none = 'none' + + +class InstancedSubdomain(BaseModel): + __root__: constr( + regex=r'^((web|tcp):)?[a-z0-9-]+$', min_length=0, max_length=10 + ) = Field( + ..., + description='The subdomain of the instanced challenge. This is applicable if the `type` is `instanced` and the challenge is hosted on a server. It will be prefixed with the instanced domain. Each entry may optionally be prefixed with `web:` or `tcp:` (e.g., `web:api`, `tcp:service`, or just `admin`). The prefix indicates the protocol associated with the subdomain.', + examples=['web', 'admin', 'api', 'tcp:service', 'web:dashboard'], + ) + + +class Flag(BaseModel): + flag: constr( + regex=r'^(\w{2,10}\{[^}]*\}|dynamic|null)$', min_length=4, max_length=1000 + ) + case_sensitive: Optional[bool] = Field( + True, + description='Whether the flag is case sensitive or not. If false, the flag will be checked case insensitively. Default is true.', + examples=[True, False], + ) + + +class DockerfileLocation(BaseModel): + location: constr(regex=r'^[a-zA-Z0-9-_/.]+$') = Field( + ..., + description='The location of the Dockerfile relative to the challenge directory', + examples=['src/Dockerfile'], + ) + context: constr(regex=r'^[a-zA-Z0-9-_/.]+$') = Field( + ..., + description='The context of the Dockerfile relative to the challenge directory', + examples=['src/'], + ) + identifier: Optional[str] = Field( + None, + description='The identifier of the Dockerfile to suffix the docker image with (None, null, or empty string for no suffix)', + examples=['None', None], + ) + + +class Model(BaseModel): + version: Optional[constr(regex=r'^(\d+\.)?(\d+\.)?(\*|\d+)$')] = Field( + '1', + description='The version of the challenge schema', + examples=['1', '1.0', '1.0.0'], + ) + enabled: bool = Field( + ..., + description='Whether the challenge is enabled or not. If disabled, this forces the challenge to not be deployed or interacted with.\nShould only be false if the challenge is not ready.', + ) + name: constr(min_length=1, max_length=50) = Field( + ..., description='The name of the challenge', examples=['Example Challenge'] + ) + slug: constr(regex=r'^[a-z0-9-]+$', min_length=1, max_length=50) = Field( + ..., description='The slug of the challenge', examples=['example-challenge'] + ) + author: constr(min_length=1, max_length=100) = Field( + ..., description='The author of the challenge', examples=['John Smith'] + ) + tags: Optional[ + List[constr(regex=r'^[a-zA-Z0-9-_:;? ]+$', min_length=1, max_length=50)] + ] = Field( + [], description='Tags for the challenge. Used for filtering and searching.' + ) + prerequisites: Optional[List[Prerequisite]] = Field( + None, + description='Required challenges to solve before this challenge can be displayed', + unique_items=True, + ) + category: Category = Field(..., description='The category of the challenge') + difficulty: Difficulty = Field(..., description='The difficulty of the challenge') + type: Type = Field( + ..., + description='The type of challenge.\nStatic challenges represent challenges utilizing delivered files and information.\nShared challenges represent challenges that are hosted on a server and shared among multiple teams.\nInstanced challenges represent challenges that are hosted on a server and are unique to each user/team.', + ) + instanced_type: Optional[InstancedType] = Field( + 'none', + description='The type of instanced challenge.\nDefines how users interact with the challenge.', + ) + instanced_name: Optional[ + constr(regex=r'^[a-z0-9-]+$', min_length=1, max_length=50) + ] = None + instanced_subdomains: Optional[List[InstancedSubdomain]] = Field( + [], + description='The subdomains of the instanced challenge. This is applicable if the `type` is `instanced` and the challenge is hosted on a server. It will be prefixed with the instanced domain.', + examples=[['web', 'admin', 'api'], ['api']], + max_items=5, + min_items=0, + unique_items=True, + ) + connection: Optional[constr(max_length=255)] = Field( + None, + description='The connection string for the challenge. This is applicable if the `type` is not `instanced` and the challenge is hosted on a server. It is used to display connection information for the challenge. You can use the variable `${host}` in the string, which will be replaced with the website domain when rendered.', + examples=['http://example.com', 'nc example.com 1337', 'nc ${host} 1337'], + ) + points: Optional[conint(ge=1, le=10000)] = Field( + 1000, description='The amount of points the challenge is worth from the start' + ) + decay: Optional[conint(ge=0, le=10000)] = Field( + 75, description='The percentage of points lost over time' + ) + min_points: Optional[conint(ge=1, le=1000)] = Field( + 100, + description='The minimum amount of points the challenge can be worth. min_points must be less than or equal to points.', + ) + flag: Union[ + constr( + regex=r'^(\w{2,10}\{[^}]*\}|dynamic|null)$', min_length=4, max_length=1000 + ), + List[ + Union[ + constr( + regex=r'^(\w{2,10}\{[^}]*\}|dynamic|null)$', + min_length=4, + max_length=1000, + ), + Flag, + ] + ], + ] = Field( + ..., + description="The flag of the challenge. May be a single string or an array of strings. Each may be 'dynamic', 'null', or a flag like 'flag{...}'. `flag` may be replaced with between 2 and 10 word chars. If case sensitivity is not explicitly specified, the flag is case sensitive.", + examples=[ + 'flag{flag}', + ['flag{flag1}', 'flag{flag2}'], + [ + {'flag': 'flag{flag1}', 'case_sensitive': True}, + {'flag': 'flag{flag2}', 'case_sensitive': False}, + ], + ], + ) + description_location: Optional[constr(regex=r'^[a-zA-Z0-9-_/.]+\.md$')] = Field( + 'description.md', + description='The location of the description file relative to the challenge directory', + examples=['description.md'], + ) + dockerfile_locations: Optional[List[DockerfileLocation]] = Field( + [], + description='The locations of the Dockerfiles relative to the challenge directory.\nMultiple Dockerfiles can be specified if the challenge requires multiple images.', + ) + handout_dir: Optional[constr(regex=r'^[a-zA-Z0-9-_/]+$')] = Field( + 'handout', + description='The location of the handout directory relative to the challenge directory. Handout directory contains all files that are handed out to users (zipped to a single file that are stored in _.zip in the files directory).', + examples=['handout'], + ) diff --git a/src/library/config.py b/src/library/config.py new file mode 100644 index 0000000..f12023c --- /dev/null +++ b/src/library/config.py @@ -0,0 +1,26 @@ +CHALL_TYPES = [ "static", "shared", "instanced" ] +DIFFICULTIES = [ "beginner", "easy", "easy-medium", "medium", "medium-hard","hard", "very-hard", "insane"] +CATEGORIES = [ "web", "forensics", "rev", "crypto", "pwn", "boot2root", "osint", "misc", "blockchain", "mobile", "test" ] +TAG_FORMAT = "^[a-zA-Z0-9-_:;? ]+$" +FLAG_FORMAT = "^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$" +INSTANCED_TYPES = [ "none", "web", "tcp" ] # "none" is the default. Defines how users interact with the challenge. +DEFAULT = { + "enabled": False, + "name": None, + "slug": None, + "author": None, + "category": None, + "difficulty": None, + "type": None, + "tags": [], + "instanced_name": None, + "instanced_type": "none", + "instanced_subdomains": [], + "connection": None, + "flag": {"flag": "null", "case_sensitive": False}, + "points": 1000, + "decay": 75, + "min_points": 100, + "description_location": "description.md", + "handout_dir": "handout" +} diff --git a/src/library/data.py b/src/library/data.py new file mode 100644 index 0000000..4b30210 --- /dev/null +++ b/src/library/data.py @@ -0,0 +1,709 @@ +import re +import json as _json +import yaml as _yaml + +from dataclasses import dataclass, field +from typing import List, Optional, Union +from pathlib import Path + + +from .utils import Utils +from .config import CHALL_TYPES, DIFFICULTIES, CATEGORIES, TAG_FORMAT, INSTANCED_TYPES, FLAG_FORMAT, DEFAULT + +@dataclass +class DockerfileLocation: + location: str + context: str + identifier: Optional[str] = None + + def __init__(self, location, context, identifier): + self.set_location(location) + self.set_context(context) + self.set_identifier(identifier) + + def set_location(self, location: str): + if not re.match(r'^[a-zA-Z0-9-_/\.]+$', location): + print("Dockerfile location must be a valid file path to a Dockerfile.") + raise ValueError("Dockerfile location must be a valid file path to a Dockerfile.") + + self.location = location + + def set_context(self, context: str): + if not re.match(r'^[a-zA-Z0-9-_/\.]+$', context): + print("Dockerfile context must be a valid file path.") + raise ValueError("Dockerfile context must be a valid file path.") + + self.context = context + + def set_identifier(self, identifier: Optional[str]): + identifier = Utils.slugify(identifier) or None + + if (identifier == None): + self.identifier = None + return + + if identifier != None and Utils.validate_length(identifier, 1, 50, "identifier") == False: + raise ValueError("Identifier must be between 1 and 50 characters.") + + self.identifier = identifier + +@dataclass +class ChallengeFlag: + flag: str + case_sensitive: bool = False + + def __init__(self, flag: str, case_sensitive: bool = False): + if not Utils.validate_length(flag, 1, 1000, "flag"): + raise ValueError("Flag must be between 1 and 1000 characters.") + + flag = flag.strip().replace('\n', '').replace('\r', '') + if not re.match(FLAG_FORMAT, flag): + print("Flag must be in the format: " + FLAG_FORMAT) + raise ValueError("The flag \""+flag+"\" must be in the format: " + FLAG_FORMAT) + + self.flag = flag + self.case_sensitive = case_sensitive + + def to_dict(self): + return { + "flag": self.flag, + "case_sensitive": self.case_sensitive + } + +@dataclass +class Challenge: + name: str + slug: str + author: str + category: str + difficulty: str + type: str + tags: Optional[List[str]] = field(default_factory=list) + instanced_type: str = "none" + instanced_name: Optional[str] = None + instanced_subdomains: List[str] = field(default_factory=list) + connection: Optional[str] = None + flag: Optional[List[ChallengeFlag]] = None + enabled: bool = True + points: int = 1000 + decay: int = 75 + min_points: int = 100 + description_location: str = "description.md" + handout_dir: str = "handout" + dockerfile_locations: List[DockerfileLocation] = field(default_factory=list) + prerequisites: List[str] = field(default_factory=list) + + def __init__( + self, + enabled: bool = True, + name: Optional[str] = None, + slug: Optional[str] = None, + author: Optional[str] = None, + category: Optional[str] = None, + difficulty: Optional[str] = None, + type: Optional[str] = None, + instanced_type: Optional[str] = None, + instanced_name: Optional[str] = None, + instanced_subdomains: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + connection: Optional[str] = None, + flag: Optional[Union[str, List[str], List[dict], ChallengeFlag, List[ChallengeFlag]]] = None, + points: Optional[int] = None, + decay: Optional[int] = None, + min_points: Optional[int] = None, + description_location: Optional[str] = None, + handout_dir: Optional[str] = None + ): + # Insert default values from DEFAULT + if enabled != None: + self.set_enabled(enabled) + else: self.set_enabled(DEFAULT['enabled']) + if name != None: + self.set_name(name) + else: self.set_name(DEFAULT['name']) + if slug != None: + self.set_slug(slug) + else: self.set_slug(DEFAULT['slug']) + if author != None: + self.set_author(author) + else: self.set_author(DEFAULT['author']) + if category != None: + self.set_category(category) + else: self.set_category(DEFAULT['category']) + if difficulty != None: + self.set_difficulty(difficulty) + else: self.set_difficulty(DEFAULT['difficulty']) + if type != None: + self.set_type(type) + else: self.set_type(DEFAULT['type']) + if instanced_type != None: + self.set_instanced_type(instanced_type) + else: self.set_instanced_type(DEFAULT['instanced_type']) + if tags != None: + self.set_tags(tags) + else: self.set_tags(DEFAULT['tags']) + if instanced_name != None: + self.instanced_name = instanced_name + else: self.instanced_name = DEFAULT['instanced_name'] + if instanced_subdomains != None: + self.set_instanced_subdomains(instanced_subdomains) + else: self.instanced_subdomains = DEFAULT['instanced_subdomains'] + if connection != None: + self.set_connection(connection) + else: self.connection = DEFAULT['connection'] + if flag != None: + self.set_flag(flag) + else: self.set_flag(DEFAULT['flag']) + if points != None: + self.set_points(points) + else: self.set_points(DEFAULT['points']) + if decay != None: + self.set_decay(decay) + else: self.set_decay(DEFAULT['decay']) + if min_points != None: + self.set_min_points(min_points) + else: self.set_min_points(DEFAULT['min_points']) + if description_location != None: + self.set_description_location(description_location) + else: self.set_description_location(DEFAULT['description_location']) + if handout_dir != None: + self.set_handout_dir(handout_dir) + else: self.set_handout_dir(DEFAULT['handout_dir']) + + self.prerequisites = [] + self.dockerfile_locations = [] + + + def set_enabled(self, enabled: bool): + self.enabled = enabled + + def set_name(self, name: str): + if Utils.validate_length(name, 1, 50, "name") == False: + raise ValueError("Name must be between 1 and 50 characters.") + + self.name = name + + def set_slug(self, slug: str): + slug = Utils.slugify(slug) or "" + + if Utils.validate_length(slug, 1, 50, "slug") == False: + raise ValueError("Slug must be between 1 and 50 characters.") + + self.slug = slug + + def set_author(self, author: str): + if Utils.validate_length(author, 1, 100, "author") == False: + raise ValueError("Author must be between 1 and 100 characters.") + + self.author = author + + def set_category(self, category: str): + if Utils.validate_length(category, 1, 50, "category") == False: + raise ValueError("Category must be between 1 and 50 characters.") + + if category not in CATEGORIES: + print("Category must be one of the following: " + ", ".join(CATEGORIES)) + raise ValueError("Invalid category provided. Category must be one of the following: " + ", ".join(CATEGORIES)) + + self.category = category + + def set_difficulty(self, difficulty: str): + if difficulty == None: + print("Difficulty must be provided.") + raise ValueError("Difficulty must be provided.") + + difficulty = difficulty.lower() + if difficulty not in DIFFICULTIES: + print("Difficulty must be one of the following: " + ", ".join(DIFFICULTIES)) + raise ValueError("Invalid difficulty provided. Difficulty must be one of the following: " + ", ".join(DIFFICULTIES)) + + self.difficulty = difficulty + + def set_type(self, type: str): + if type == None: + print("Type must be provided.") + raise ValueError("Type must be provided.") + + type = type.lower() + if type not in CHALL_TYPES: + print("Type must be one of the following: " + ", ".join(CHALL_TYPES)) + raise ValueError("Invalid type provided. Type must be one of the following: " + ", ".join(CHALL_TYPES)) + self.type = type + + def set_tags(self, tags: List[str]): + if not isinstance(tags, list): + print("Tags must be a list of strings.") + raise ValueError("Tags must be a list of strings.") + + for tag in tags: + if not re.match(TAG_FORMAT, tag): + print(f"Tag '{tag}' does not match the required format: {TAG_FORMAT}") + raise ValueError(f"Tag '{tag}' does not match the required format: {TAG_FORMAT}") + + self.tags = tags + + def set_points(self, points: int): + if points < 1 or points > 10000: + print("Points must be between 1 and 10000.") + raise ValueError("Points must be between 1 and 10000.") + + self.points = points + + def set_decay(self, decay: int): + if decay < 0 or decay > 10000: + print("Decay must be between 0 and 10000.") + raise ValueError("Decay must be between 0 and 10000.") + + self.decay = decay + + def set_min_points(self, min_points: int): + if min_points < 1 or min_points > 1000: + print("Minimum points must be between 1 and 1000.") + raise ValueError("Minimum points must be between 1 and 1000.") + + self.min_points = min_points + + def set_instanced_subdomains(self, instanced_subdomains: List[str]): + if not isinstance(instanced_subdomains, list): + print("Instanced subdomains must be a list of strings.") + raise ValueError("Instanced subdomains must be a list of strings.") + + if len(instanced_subdomains) > 5: + print("Instanced subdomains must not exceed 5 items.") + raise ValueError("Instanced subdomains must not exceed 5 items.") + + for subdomain in instanced_subdomains: + if not re.match(r'^((web|tcp):)?[a-z0-9-]+$', subdomain): + print(f"Subdomain '{subdomain}' does not match the required format: ^((web|tcp):)?[a-z0-9-]+$") + raise ValueError(f"Subdomain '{subdomain}' does not match the required format: ^((web|tcp):)?[a-z0-9-]+$") + + if len(subdomain) > 10: + print(f"Subdomain '{subdomain}' exceeds the maximum length of 10 characters.") + raise ValueError(f"Subdomain '{subdomain}' exceeds the maximum length of 10 characters.") + + self.instanced_subdomains = instanced_subdomains + + def set_connection(self, connection: Optional[str]): + if connection is not None and not isinstance(connection, str): + print("Connection must be a string or None.") + raise ValueError("Connection must be a string or None.") + + # Max length of 255 + if Utils.validate_length(connection, 1, 255, "connection") == False: + raise ValueError("Connection string must be between 1 and 255 characters.") + + self.connection = connection + + def set_flag(self, flag): + if isinstance(flag, list): + clean_flags = [] + for f in flag: + if isinstance(f, ChallengeFlag): + clean_flags.append(f) + elif isinstance(f, str): + clean_flags.append(ChallengeFlag(f)) + elif isinstance(f, dict): + if 'flag' not in f or not isinstance(f['flag'], str): + raise ValueError("Each flag dictionary must contain a 'flag' key with a string value.") + case_sensitive = f.get('case_sensitive', False) + clean_flags.append(ChallengeFlag(f['flag'], case_sensitive)) + if not clean_flags: + raise ValueError("No valid flags provided in list.") + self.flag = clean_flags + elif isinstance(flag, str): + self.flag = [ChallengeFlag(flag)] + elif isinstance(flag, ChallengeFlag): + self.flag = [flag] + else: + self.flag = None + + def set_instanced_type(self, instanced_type: str): + instanced_type = instanced_type.lower() + if instanced_type not in INSTANCED_TYPES: + print("Instanced type must be one of the following: " + ", ".join(INSTANCED_TYPES)) + raise ValueError("Invalid instanced type provided. Instanced type must be one of the following: " + ", ".join(INSTANCED_TYPES)) + + self.instanced_type = instanced_type + + def set_instanced_name(self, instanced_name: Optional[str]): + instanced_name = Utils.slugify(instanced_name) + + if Utils.validate_length(instanced_name, 1, 50, "instanced_name") == False: + raise ValueError("Instanced name must be between 1 and 50 characters.") + + self.instanced_name = instanced_name + + def set_description_location(self, description_location: str): + if not re.match(r'^[a-zA-Z0-9-_/]+.md$', description_location): + print("Description location must be a valid file path to a Markdown file.") + raise ValueError("Description location must be a valid file path to a Markdown file.") + + self.description_location = description_location + + def get_description(self): + file = self.get_path().joinpath(self.description_location) + + if not file.exists(): + return "" + + with open(file, 'r') as f: + return f.read() + + def set_handout_dir(self, handout_dir: str): + if not re.match(r'^[a-zA-Z0-9-_/]+$', handout_dir): + print("Handout directory must be a valid file path.") + raise ValueError("Handout directory must be a valid file path.") + + self.handout_dir = handout_dir + + def add_dockerfile_location(self, locations: List[DockerfileLocation]): + self.dockerfile_locations.extend(locations) + + def add_prerequisite(self, prerequisite: Optional[str]): + prerequisite = Utils.slugify(prerequisite) + + if Utils.validate_length(prerequisite, 1, 50, "prerequisite") == False: + raise ValueError("Prerequisite must be between 1 and 50 characters.") + + if prerequisite in self.prerequisites: + print(f"Prerequisite {prerequisite} already exists.") + raise ValueError("Prerequisite already exists.") + + if prerequisite == None: + print("Prerequisite must be provided.") + raise ValueError("Prerequisite must be provided.") + + self.prerequisites.append(prerequisite) + + def get_version(self): + file = self.get_path().joinpath('version') + + if not file.exists(): + return 0 + + with open(file, 'r') as f: + return int(f.read()) + + def save_version(self, version: int): + file = self.get_path().joinpath('version') + + with open(file, 'w') as f: + f.write(str(version)) + + def get_path(self): + return Utils.get_challenge_dir(self.category, self.slug) + + def generate_dict(self, schema_location: str): + # Use a local variable for flag to avoid modifying self.flag + flag = [f.to_dict() for f in self.flag] if self.flag else None + tags = self.tags if self.tags else [] + + data = { + "$schema": schema_location, + "enabled": self.enabled, + "name": self.name, + "slug": self.slug, + "author": self.author, + "category": self.category, + "difficulty": self.difficulty, + "tags": tags, + "type": self.type, + "instanced_type": self.instanced_type, + "instanced_name": self.instanced_name, + "instanced_subdomains": self.instanced_subdomains, + "connection": self.connection, + "flag": flag, + "points": self.points, + "decay": self.decay, + "min_points": self.min_points, + "description_location": self.description_location, + "handout_dir": self.handout_dir + } + if self.dockerfile_locations: + data["dockerfile_locations"] = [ + { + "location": loc.location, + "context": loc.context, + "identifier": loc.identifier + } for loc in self.dockerfile_locations + ] + if self.prerequisites: + data["prerequisites"] = self.prerequisites + return data + + def str_yml(self, schema_location: str): + data = self.generate_dict(schema_location) + # Remove $schema from dict for yaml, as it will be added as a comment + schema = data.pop("$schema", None) + yml_str = f"# yaml-language-server: $schema={schema}\n\n" + yml_str += _yaml.dump(data, sort_keys=False, allow_unicode=True) + return yml_str + + def str_json(self, schema_location: str): + data = self.generate_dict(schema_location) + return _json.dumps(data, indent=2) + + def __str__(self): + return self.str_yml("-") + + @staticmethod + def load_from_yaml(yml: dict): + challenge = Challenge( + enabled=yml.get("enabled", True), + name=yml.get("name", None), + slug=yml.get("slug", None), + author=yml.get("author", None), + category=yml.get("category", None), + difficulty=yml.get("difficulty", None), + type=yml.get("type", None), + tags=yml.get("tags", []), + instanced_type=yml.get("instanced_type", "none"), + instanced_name=yml.get("instanced_name", None), + instanced_subdomains=yml.get("instanced_subdomains", []), + connection=yml.get("connection", None), + flag=yml.get("flag", None), + points=yml.get("points", 1000), + decay=yml.get("decay", 75), + min_points=yml.get("min_points", 100), + description_location=yml.get("description_location", "description.md"), + handout_dir=yml.get("handout_dir", "handout") + ) + + dockerfile_locations = yml.get("dockerfile_locations", []) + for location in dockerfile_locations: + challenge.add_dockerfile_location([DockerfileLocation(location.get("location", "src/Dockerfile"), location.get("context", "src/"), location.get("identifier", None))]) + + prerequisites = yml.get("prerequisites", []) + for prerequisite in prerequisites: + challenge.add_prerequisite(prerequisite) + + return challenge + + @staticmethod + def load_from_json(json_data: dict): + challenge = Challenge( + enabled=json_data.get("enabled", True), + name=json_data.get("name", None), + slug=json_data.get("slug", None), + author=json_data.get("author", None), + category=json_data.get("category", None), + difficulty=json_data.get("difficulty", None), + tags=json_data.get("tags", []), + type=json_data.get("type", None), + instanced_type=json_data.get("instanced_type", "none"), + instanced_name=json_data.get("instanced_name", None), + instanced_subdomains=json_data.get("instanced_subdomains", []), + connection=json_data.get("connection", None), + flag=json_data.get("flag", None), + points=json_data.get("points", 1000), + decay=json_data.get("decay", 75), + min_points=json_data.get("min_points", 100), + description_location=json_data.get("description_location", "description.md"), + handout_dir=json_data.get("handout_dir", "handout") + ) + + dockerfile_locations = json_data.get("dockerfile_locations", []) + for location in dockerfile_locations: + challenge.add_dockerfile_location([DockerfileLocation(location.get("location", "src/Dockerfile"), location.get("context", "src/"), location.get("identifier", None))]) + + prerequisites = json_data.get("prerequisites", []) + for prerequisite in prerequisites: + challenge.add_prerequisite(prerequisite) + + return challenge + + @staticmethod + def load(file): + if file.endswith(".yml") or file.endswith(".yaml"): + print("Loading from yml file") + return Challenge.load_from_yaml(Utils.load_yaml(file)) + elif file.endswith(".json"): + print("Loading from json file") + return Challenge.load_from_json(Utils.load_json(file)) + else: + print("File must be either a yml or json file.") + raise ValueError("File must be either a yml or json file.") + + @staticmethod + def load_dir(directory: Path): + # Check for yml or json file + path = Path(directory) + for file in path.iterdir(): + if file.is_file(): + if file.name.endswith(".yml") or file.name.endswith(".yaml"): + print("Loading from yml file") + return Challenge.load_from_yaml(Utils.load_yaml(file)) + elif file.name.endswith(".json"): + print("Loading from json file") + return Challenge.load_from_json(Utils.load_json(file)) + +@dataclass +class Page: + enabled: bool = True + slug: str = "" + title: str = "" + route: str = "" + content: str = "page.md" + format: str = "markdown" + auth: Optional[bool] = False + draft: Optional[bool] = False + + def __init__( + self, + enabled: bool = True, + slug: Optional[str] = None, + title: Optional[str] = None, + route: Optional[str] = None, + content: Optional[str] = None, + format: Optional[str] = None, + auth: Optional[bool] = None, + draft: Optional[bool] = None, + ): + if enabled != None: + self.set_enabled(enabled) + if slug != None: + self.set_slug(slug) + if title != None: + self.set_title(title) + if route != None: + self.set_route(route) + if content != None: + self.set_content(content) + if format != None: + self.set_format(format) + if auth != None: + self.set_auth(auth) + if draft != None: + self.set_draft(draft) + + def set_enabled(self, enabled: bool): + self.enabled = enabled + + def set_slug(self, slug: str): + if not Utils.validate_length(slug, 1, 50, "slug"): + raise ValueError("Slug must be between 1 and 50 characters.") + self.slug = slug + + def set_title(self, title: str): + if not Utils.validate_length(title, 1, 100, "title"): + raise ValueError("Title must be between 1 and 100 characters.") + self.title = title + + def set_route(self, route: str): + if not Utils.validate_length(route, 1, 100, "route"): + raise ValueError("Route must be between 1 and 100 characters.") + self.route = route + + def set_content(self, content: str): + if not re.match(r'^[a-zA-Z0-9-_.]+\.(md|html|txt)$', content): + raise ValueError("Content must be a valid file path ending in .md, .html, or .txt.") + self.content = content + + def set_format(self, format: str): + if format not in ["markdown", "html"]: + raise ValueError("Format must be either 'markdown' or 'html'.") + self.format = format + + def set_auth(self, auth: Optional[bool]): + self.auth = auth if auth is not None else False + + def set_draft(self, draft: Optional[bool]): + self.draft = draft if draft is not None else False + + def get_version(self): + file = self.get_path().joinpath('version') + + if not file.exists(): + return 0 + + with open(file, 'r') as f: + return int(f.read()) + + def save_version(self, version: int): + file = self.get_path().joinpath('version') + + with open(file, 'w') as f: + f.write(str(version)) + + def get_path(self): + return Utils.get_page_dir(self.slug) + + def generate_dict(self, schema_location: str): + return { + "$schema": schema_location, + "enabled": self.enabled, + "slug": self.slug, + "title": self.title, + "route": self.route, + "content": self.content, + "format": self.format, + "auth": self.auth, + "draft": self.draft, + } + + def str_yml(self, schema_location: str): + data = self.generate_dict(schema_location) + schema = data.pop("$schema", None) + yml_str = f"# yaml-language-server: $schema={schema}\n\n" + yml_str += _yaml.dump(data, sort_keys=False, allow_unicode=True) + return yml_str + + def str_json(self, schema_location: str): + data = self.generate_dict(schema_location) + return _json.dumps(data, indent=2) + + def __str__(self): + return self.str_yml("-") + + @staticmethod + def load_from_yaml(yml: dict): + if yml is None: + raise ValueError("YAML data must not be None.") + return Page( + enabled=yml.get("enabled", True), + slug=yml.get("slug", ""), + title=yml.get("title", ""), + route=yml.get("route", ""), + content=yml.get("content", "page.md"), + format=yml.get("format", "markdown"), + auth=yml.get("auth", False), + draft=yml.get("draft", False), + ) + + @staticmethod + def load_from_json(json_data: dict): + if json_data is None: + raise ValueError("JSON data must not be None.") + return Page( + enabled=json_data.get("enabled", True), + slug=json_data.get("slug", ""), + title=json_data.get("title", ""), + route=json_data.get("route", ""), + content=json_data.get("content", "page.md"), + format=json_data.get("format", "markdown"), + auth=json_data.get("auth", False), + draft=json_data.get("draft", False), + ) + + @staticmethod + def load(file): + if file.endswith(".yml") or file.endswith(".yaml"): + return Page.load_from_yaml(Utils.load_yaml(file)) + elif file.endswith(".json"): + return Page.load_from_json(Utils.load_json(file)) + else: + raise ValueError("File must be either a yml or json file.") + + @staticmethod + def load_dir(directory: Path): + # Check for yml or json file + path = Path(directory) + for file in path.iterdir(): + if file.is_file(): + if file.name.endswith(".yml") or file.name.endswith(".yaml"): + print("Loading from yml file") + return Page.load_from_yaml(Utils.load_yaml(file)) + elif file.name.endswith(".json"): + print("Loading from json file") + return Page.load_from_json(Utils.load_json(file)) + \ No newline at end of file diff --git a/src/library/generator.py b/src/library/generator.py new file mode 100644 index 0000000..feade57 --- /dev/null +++ b/src/library/generator.py @@ -0,0 +1,312 @@ +import os +import sys + +from typing import Literal, Optional +from pathlib import Path + +from .data import Challenge, Page +from .utils import Utils + +class Generator: + challenge: Challenge + page: Page + path: Path + + dir_src: str + dir_template: str + dir_files: str + dir_handout: str + dir_solvescript: str + dir_k8s: str + + def __init__(self, challenge: Optional[Challenge] = None, page: Optional[Page] = None): + if not challenge and not page: + print("No challenge or page provided") + sys.exit(1) + + if challenge: + self.challenge = challenge + self.path = Utils.get_challenge_dir(challenge.category, challenge.slug) + if page: + self.page = page + self.path = Utils.get_page_dir(page.slug) + + # Directories + self.dir_src = os.path.join(self.path, "src") + self.dir_template = os.path.join(self.path, "template") + self.dir_solvescript = os.path.join(self.path, "solution") + + self.dir_k8s = os.path.join(self.path, "k8s") + self.dir_files = os.path.join(self.dir_k8s, "files") + self.dir_handout = os.path.join(self.path, "handout") + + # --- Main functions --- + + def build(self): + self.src_directory() + self.solvescript_directory() + self.files_directory() + self.handout_directory() + + self.challenge_file(format="yml") + self.readme_file() + self.description_file() + self.version_file() + + if self.challenge.type != "static": + self.template_directory() + self.k8s_directory() + + self.dockerfile() + + self.instanced_template_file() + + # --- Helper functions --- + + def check_if_dir_exists(self, dir_path): + if not os.path.exists(dir_path): + return False + return True + + def create_directory_if_not_exists(self, dir_path, dir_name): + if not self.check_if_dir_exists(dir_path): + print(f"Creating {dir_name} {dir_path}") + os.makedirs(dir_path) + return True + else: + print(f"{dir_name} {dir_path} already exists, skipping creation") + return False + + def write_file(self, file_path, content): + with open(file_path, "w") as f: + f.write(content) + + # --- Directories --- + + def chall_directory_exists(self): + return self.check_if_dir_exists(self.path) + + def chall_directory(self): + return self.create_directory_if_not_exists(self.path, "challenge directory") + + def src_directory_exists(self): + return self.check_if_dir_exists(self.dir_src) + + def src_directory(self): + success = self.create_directory_if_not_exists(self.dir_src, "source directory") + + if success: + self.write_file(os.path.join(self.dir_src, ".gitkeep"), """# This file is used to keep the directory in the repository. +# This directory is used to store source files for the challenge.""") + + return success + + def solvescript_directory_exists(self): + return self.check_if_dir_exists(self.dir_solvescript) + + def solvescript_directory(self): + success = self.create_directory_if_not_exists(self.dir_solvescript, "solution directory") + + if success: + self.write_file(os.path.join(self.dir_solvescript, "README.md"), """# Solution +This directory is used to store the solution script for the challenge. +This file should contain the steps to solve the challenge.""") + + return success + + def template_directory_exists(self): + return self.check_if_dir_exists(self.dir_template) + + def template_directory(self): + success = self.create_directory_if_not_exists(self.dir_template, "template directory") + + if success: + self.write_file(os.path.join(self.dir_template, ".gitkeep"), """# This file is used to keep the directory in the repository. +# This directory is used to store templates for the challenge deployment.""") + + return success + + def files_directory_exists(self): + return self.check_if_dir_exists(self.dir_files) + + def files_directory(self): + success = self.create_directory_if_not_exists(self.dir_files, "files directory") + + if success: + self.write_file(os.path.join(self.dir_files, ".gitkeep"), """# This file is used to keep the directory in the repository. +# This directory is used to store files that are handed out, for the challenge. Use the handout directory for files that are handed out to users and want to be packaged as a zip file.""") + + return success + + def handout_directory_exists(self): + return self.check_if_dir_exists(self.dir_handout) + + def handout_directory(self): + success = self.create_directory_if_not_exists(self.dir_handout, "handout directory") + + if success: + self.write_file(os.path.join(self.dir_handout, ".gitkeep"), """# This file is used to keep the directory in the repository. +# This directory is used to store files that are handed out, for the challenge. The files are automatically zipped and copied to the files directory.""") + + def k8s_directory_exists(self): + return self.check_if_dir_exists(self.dir_k8s) + + def k8s_directory(self): + success = self.create_directory_if_not_exists(self.dir_k8s, "k8s directory") + + if success: + self.write_file(os.path.join(self.dir_k8s, ".gitkeep"), """# This file is used to keep the directory in the repository. +# This directory is used to store Kubernetes deployment files for the challenge.""") + + return success + + # --- Files --- + + def challenge_file_exists(self): + # Check yml, yaml and json files + if self.check_if_dir_exists(os.path.join(self.path, "challenge.yml")): + return True + if self.check_if_dir_exists(os.path.join(self.path, "challenge.yaml")): + return True + if self.check_if_dir_exists(os.path.join(self.path, "challenge.json")): + return True + return False + + def challenge_file(self, format: Literal["yml", "yaml", "json"] = "yml"): + if self.challenge_file_exists(): + print("Challenge file already exists!") + return False + + # Create challenge file + path = Path(self.path) + path = path.joinpath(f"challenge.{format}") + content = self.challenge.str_yml("./../../../tools/schema/challenge-schema.json") + if format == "json": + content = self.challenge.str_json("./../../../tools/schema/challenge-schema.json") + + with open(path, "w") as f: + f.write(content) + f.write("\n") + + print(f"File created: {path}") + + return True + + def readme_file_exists(self): + return self.check_if_dir_exists(os.path.join(self.path, "README.md")) + + def readme_file(self): + if self.readme_file_exists(): + print("README file already exists!") + return False + + # Create README file + path = os.path.join(self.path, "README.md") + with open(path, "w") as f: + f.write(f"# {self.challenge.name}\n\n") + f.write("*Add information about challenge here* \n") + f.write("*It is meant to contian internal documentation of the challenge, such as how it is solved*\n") + + print(f"File created: {path}") + + return True + + def description_file_exists(self): + return self.check_if_dir_exists(os.path.join(self.path, "description.md")) + + def description_file(self): + if self.description_file_exists(): + print("Description file already exists!") + return False + + # Create description file + path = os.path.join(self.path, "description.md") + with open(path, "w") as f: + f.write(f"# {self.challenge.name}\n\n") + f.write(f"**Difficulty:** {self.challenge.difficulty.capitalize()} \n") + f.write(f"**Author:** {self.challenge.author} \n") + f.write("\n") + f.write("*Add challenge description here*\n") + + print(f"File created: {path}") + + return True + + def dockerfile_exists(self): + return self.check_if_dir_exists(os.path.join(self.dir_src, "Dockerfile")) + + def dockerfile(self): + if self.dockerfile_exists(): + print("Dockerfile already exists!") + return False + + # Create Dockerfile + path = os.path.join(self.dir_src, "Dockerfile") + with open(path, "w") as f: + f.write(f"# Dockerfile for {self.challenge.category} - {self.challenge.name}\n") + f.write("FROM ubuntu:latest\n") + f.write("\n") + f.write("RUN apt-get update && apt-get install -y python3\n") + f.write("\n") + f.write("CMD [\"/bin/bash\"]\n") + + print(f"File created: {path}") + + return True + + def instanced_template_source_file_exists(self): + return self.check_if_dir_exists(os.path.join(Utils.get_template_dir(), "instanced-web-k8s.yml")) and \ + self.check_if_dir_exists(os.path.join(Utils.get_template_dir(), "instanced-tcp-k8s.yml")) + + def instanced_template_file_exists(self): + return self.check_if_dir_exists(os.path.join(self.dir_template, "k8s.yml")) + + def instanced_template_file(self): + # Check if needed template exists + if not self.instanced_template_source_file_exists(): + print("k8s template files not found!") + return False + + # Check if template directory to write to exists + if not self.check_if_dir_exists(self.dir_template): + print("Template directory not found!") + return False + + # Check if template file already exists + if self.instanced_template_file_exists(): + print("Template file already exists!") + return False + + if self.challenge.instanced_type == "web": + source_file = os.path.join(Utils.get_template_dir(), "instanced-web-k8s.yml") + elif self.challenge.instanced_type == "tcp": + source_file = os.path.join(Utils.get_template_dir(), "instanced-tcp-k8s.yml") + else: + print(f"Instanced type {self.challenge.instanced_type} is not supported for instanced challenges.") + return False + + output_file = os.path.join(self.dir_template, "k8s.yml") + with open(source_file, "r") as f: + with open(output_file, "w") as of: + of.write(f.read()) + + print(f"File created: {output_file}") + + return True + + def version_file_exists(self): + return self.check_if_dir_exists(os.path.join(self.path, "version")) + + def version_file(self): + if self.version_file_exists(): + print("Version file already exists!") + return False + + # Create VERSION file + path = os.path.join(self.path, "version") + with open(path, "w") as f: + f.write("1") + + print(f"File created: {path}") + + return True diff --git a/src/library/utils.py b/src/library/utils.py new file mode 100644 index 0000000..eaf3230 --- /dev/null +++ b/src/library/utils.py @@ -0,0 +1,86 @@ +from pathlib import Path +from slugify import slugify +import yaml +import json + +class Utils: + @staticmethod + def get_repo_dir(): + return Path(__file__).resolve().parents[2] + + @staticmethod + def get_challenges_dir(): + return Utils.get_repo_dir().joinpath('challenges') + + @staticmethod + def get_pages_dir(): + return Utils.get_repo_dir().joinpath('pages') + + @staticmethod + def get_challenge_dir(category: str, slug: str): + return Utils.get_challenges_dir().joinpath(Utils.slugify(category) or "").joinpath(slug) + + @staticmethod + def get_page_dir(page: str): + return Utils.get_pages_dir().joinpath(Utils.slugify(page) or "") + + @staticmethod + def get_challenge_dir_str(category: str, slug: str): + return f"challenges/{Utils.slugify(category)}/{slug}" + + @staticmethod + def get_page_dir_str(page: str): + return f"pages/{Utils.slugify(page)}" + + @staticmethod + def get_k8s_dir(category: str, slug: str): + return Utils.get_challenge_dir(category, slug).joinpath('k8s') + + @staticmethod + def get_k8s_page_dir(page: str): + return Utils.get_page_dir(page).joinpath('k8s') + + @staticmethod + def get_challenge_render_dir(category: str, slug: str): + return Utils.get_k8s_dir(category, slug).joinpath('challenge') + + @staticmethod + def get_configmap_dir(category: str, slug: str): + return Utils.get_k8s_dir(category, slug).joinpath('config') + + @staticmethod + def get_template_dir(): + return Utils.get_repo_dir().joinpath('template') + + @staticmethod + def slugify(text): + if (text == None): + return None + + return slugify(text.strip()).strip('-').strip('_').strip('.') + + @staticmethod + def validate_length(text, min_length, max_length, identifier): + if text == None: + print(f"{identifier.capitalize()} must be provided.") + return False + + if len(text) < min_length: + print(f"{identifier.capitalize()} must be at least {min_length} characters long.") + return False + + if len(text) > max_length: + print(f"{identifier.capitalize()} cannot be longer than {max_length} characters.") + return False + + return True + + @staticmethod + def load_yaml(file): + with open(file, 'r') as f: + return yaml.safe_load(f) + + @staticmethod + def load_json(file): + with open(file, 'r') as f: + return json.load(f) diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/challenge.py b/src/models/challenge.py new file mode 100644 index 0000000..6d7b0ce --- /dev/null +++ b/src/models/challenge.py @@ -0,0 +1,307 @@ +# generated by datamodel-codegen: +# filename: https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json +# timestamp: 2025-11-26T22:40:56+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import Optional, Sequence, Union + +from pydantic import BaseModel, Field, RootModel +from typing_extensions import Annotated + + +class Tag(RootModel[str]): + root: Annotated[ + str, Field(max_length=50, min_length=1, pattern='^[a-zA-Z0-9-_:;? ]+$') + ] + + +class Prerequisite(RootModel[str]): + root: Annotated[str, Field(max_length=100, min_length=1, pattern='^[a-z0-9-]+$')] + """ + The prerequisites of the challenge. Must be the slugs of the challenges. + """ + + +class Category(Enum): + web = 'web' + forensics = 'forensics' + rev = 'rev' + crypto = 'crypto' + pwn = 'pwn' + boot2root = 'boot2root' + osint = 'osint' + misc = 'misc' + blockchain = 'blockchain' + mobile = 'mobile' + test = 'test' + + +class Difficulty(Enum): + beginner = 'beginner' + easy = 'easy' + easy_medium = 'easy-medium' + medium = 'medium' + medium_hard = 'medium-hard' + hard = 'hard' + very_hard = 'very-hard' + insane = 'insane' + + +class Type(Enum): + static = 'static' + shared = 'shared' + instanced = 'instanced' + + +class InstancedType(Enum): + web = 'web' + tcp = 'tcp' + none = 'none' + + +class InstancedName(RootModel[str]): + root: Annotated[ + str, + Field( + examples=['example-challenge'], + max_length=50, + min_length=1, + pattern='^[a-z0-9-]+$', + ), + ] + """ + The slug of the instanced challenge. This is applicable if the `type` is `instanced` and another instance is used instead of itself. May also be itself to specify that the challenge is instanced but does not use another instance. + """ + + +class InstancedSubdomain(RootModel[str]): + root: Annotated[ + str, + Field( + examples=['web', 'admin', 'api', 'tcp:service', 'web:dashboard'], + max_length=10, + min_length=0, + pattern='^((web|tcp):)?[a-z0-9-]+$', + ), + ] + """ + The subdomain of the instanced challenge. This is applicable if the `type` is `instanced` and the challenge is hosted on a server. It will be prefixed with the instanced domain. Each entry may optionally be prefixed with `web:` or `tcp:` (e.g., `web:api`, `tcp:service`, or just `admin`). The prefix indicates the protocol associated with the subdomain. + """ + + +class Flag(RootModel[str]): + root: Annotated[ + str, + Field( + examples=[ + 'flag{flag}', + ['flag{flag1}', 'flag{flag2}'], + [ + {'flag': 'flag{flag1}', 'case_sensitive': True}, + {'flag': 'flag{flag2}', 'case_sensitive': False}, + ], + ], + max_length=1000, + min_length=4, + pattern='^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$', + ), + ] + """ + The flag of the challenge. May be a single string or an array of strings. Each may be 'dynamic', 'null', or a flag like 'flag{...}'. `flag` may be replaced with between 2 and 10 word chars. If case sensitivity is not explicitly specified, the flag is case sensitive. + """ + + +class Flag11(RootModel[str]): + root: Annotated[ + str, + Field( + max_length=1000, + min_length=4, + pattern='^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$', + ), + ] + + +class Flag12(BaseModel): + flag: Annotated[ + str, + Field( + max_length=1000, + min_length=4, + pattern='^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$', + ), + ] + case_sensitive: Annotated[Optional[bool], Field(examples=[True, False])] = True + """ + Whether the flag is case sensitive or not. If false, the flag will be checked case insensitively. Default is true. + """ + + +class Flag1(RootModel[Sequence[Union[Flag11, Flag12]]]): + root: Annotated[ + Sequence[Union[Flag11, Flag12]], + Field( + examples=[ + 'flag{flag}', + ['flag{flag1}', 'flag{flag2}'], + [ + {'flag': 'flag{flag1}', 'case_sensitive': True}, + {'flag': 'flag{flag2}', 'case_sensitive': False}, + ], + ], + min_length=1, + ), + ] + """ + The flag of the challenge. May be a single string or an array of strings. Each may be 'dynamic', 'null', or a flag like 'flag{...}'. `flag` may be replaced with between 2 and 10 word chars. If case sensitivity is not explicitly specified, the flag is case sensitive. + """ + + +class DockerfileLocation(BaseModel): + location: Annotated[ + str, Field(examples=['src/Dockerfile'], pattern='^[a-zA-Z0-9-_/.]+$') + ] + """ + The location of the Dockerfile relative to the challenge directory + """ + context: Annotated[str, Field(examples=['src/'], pattern='^[a-zA-Z0-9-_/.]+$')] + """ + The context of the Dockerfile relative to the challenge directory + """ + identifier: Annotated[Optional[str], Field(examples=['None', None])] = None + """ + The identifier of the Dockerfile to suffix the docker image with (None, null, or empty string for no suffix) + """ + + +class Model(BaseModel): + version: Annotated[ + Optional[str], + Field( + examples=['1', '1.0', '1.0.0'], pattern='^(\\d+\\.)?(\\d+\\.)?(\\*|\\d+)$' + ), + ] = '1' + """ + The version of the challenge schema + """ + enabled: bool + """ + Whether the challenge is enabled or not. If disabled, this forces the challenge to not be deployed or interacted with. + Should only be false if the challenge is not ready. + """ + name: Annotated[ + str, Field(examples=['Example Challenge'], max_length=50, min_length=1) + ] + """ + The name of the challenge + """ + slug: Annotated[ + str, + Field( + examples=['example-challenge'], + max_length=50, + min_length=1, + pattern='^[a-z0-9-]+$', + ), + ] + """ + The slug of the challenge + """ + author: Annotated[str, Field(examples=['John Smith'], max_length=100, min_length=1)] + """ + The author of the challenge + """ + tags: Optional[Sequence[Tag]] = [] + """ + Tags for the challenge. Used for filtering and searching. + """ + prerequisites: Optional[Sequence[Prerequisite]] = None + """ + Required challenges to solve before this challenge can be displayed + """ + category: Category + """ + The category of the challenge + """ + difficulty: Difficulty + """ + The difficulty of the challenge + """ + type: Type + """ + The type of challenge. + Static challenges represent challenges utilizing delivered files and information. + Shared challenges represent challenges that are hosted on a server and shared among multiple teams. + Instanced challenges represent challenges that are hosted on a server and are unique to each user/team. + """ + instanced_type: Optional[InstancedType] = InstancedType.none + """ + The type of instanced challenge. + Defines how users interact with the challenge. + """ + instanced_name: Optional[InstancedName] = None + instanced_subdomains: Annotated[ + Optional[Sequence[InstancedSubdomain]], + Field(examples=[['web', 'admin', 'api'], ['api']], max_length=5, min_length=0), + ] = [] + """ + The subdomains of the instanced challenge. This is applicable if the `type` is `instanced` and the challenge is hosted on a server. It will be prefixed with the instanced domain. + """ + connection: Annotated[ + Optional[str], + Field( + examples=['http://example.com', 'nc example.com 1337', 'nc ${host} 1337'], + max_length=255, + ), + ] = None + """ + The connection string for the challenge. This is applicable if the `type` is not `instanced` and the challenge is hosted on a server. It is used to display connection information for the challenge. You can use the variable `${host}` in the string, which will be replaced with the website domain when rendered. + """ + points: Annotated[Optional[int], Field(ge=1, le=10000)] = 1000 + """ + The amount of points the challenge is worth from the start + """ + decay: Annotated[Optional[int], Field(ge=0, le=10000)] = 75 + """ + The percentage of points lost over time + """ + min_points: Annotated[Optional[int], Field(ge=1, le=1000)] = 100 + """ + The minimum amount of points the challenge can be worth. min_points must be less than or equal to points. + """ + flag: Annotated[ + Union[Flag, Flag1], + Field( + examples=[ + 'flag{flag}', + ['flag{flag1}', 'flag{flag2}'], + [ + {'flag': 'flag{flag1}', 'case_sensitive': True}, + {'flag': 'flag{flag2}', 'case_sensitive': False}, + ], + ] + ), + ] + """ + The flag of the challenge. May be a single string or an array of strings. Each may be 'dynamic', 'null', or a flag like 'flag{...}'. `flag` may be replaced with between 2 and 10 word chars. If case sensitivity is not explicitly specified, the flag is case sensitive. + """ + description_location: Annotated[ + Optional[str], + Field(examples=['description.md'], pattern='^[a-zA-Z0-9-_/.]+\\.md$'), + ] = 'description.md' + """ + The location of the description file relative to the challenge directory + """ + dockerfile_locations: Optional[Sequence[DockerfileLocation]] = [] + """ + The locations of the Dockerfiles relative to the challenge directory. + Multiple Dockerfiles can be specified if the challenge requires multiple images. + """ + handout_dir: Annotated[ + Optional[str], Field(examples=['handout'], pattern='^[a-zA-Z0-9-_/]+$') + ] = 'handout' + """ + The location of the handout directory relative to the challenge directory. Handout directory contains all files that are handed out to users (zipped to a single file that are stored in _.zip in the files directory). + """ diff --git a/src/models/page.py b/src/models/page.py new file mode 100644 index 0000000..8f1025c --- /dev/null +++ b/src/models/page.py @@ -0,0 +1,80 @@ +# generated by datamodel-codegen: +# filename: https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json +# timestamp: 2025-11-26T22:41:04+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field +from typing_extensions import Annotated + + +class Format(Enum): + markdown = 'markdown' + html = 'html' + + +class Model(BaseModel): + version: Annotated[ + Optional[str], + Field( + examples=['1', '1.0', '1.0.0'], pattern='^(\\d+\\.)?(\\d+\\.)?(\\*|\\d+)$' + ), + ] = '1' + """ + The version of the page schema + """ + enabled: bool + """ + Whether the page is enabled or not. If disabled, this forces the page to not be deployed or interacted with. + """ + slug: Annotated[ + str, + Field( + examples=['example-page'], + max_length=50, + min_length=1, + pattern='^[a-z0-9-]+$', + ), + ] + """ + The slug of the page + """ + title: Annotated[ + str, Field(examples=['Example Page'], max_length=100, min_length=1) + ] + """ + The title of the page + """ + route: Annotated[ + str, Field(examples=['/example-page'], max_length=100, min_length=1) + ] + """ + The route of the page, used for navigation + """ + content: Annotated[ + str, + Field( + examples=['page.md', 'README.md', 'example-page.md'], + max_length=255, + min_length=1, + pattern='^([a-zA-Z0-9-_./]+\\.(md|html))$', + ), + ] + """ + The path to the content file for the page. This file should be located in the repository and contain the content of the page in Markdown or HTML format. + """ + auth: Optional[bool] = False + """ + Whether the page requires authentication to view. If true, only authenticated users can access the page. + """ + draft: Optional[bool] = False + """ + Whether the page is a draft or not. If true, this indicates that the page is still under development and not ready for public viewing. + """ + format: Format + """ + The format of the page content. Can be either 'markdown' or 'html'. This determines how the content is rendered on the page. + """ diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..9d275bf --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,2 @@ +pyyaml==6.0.2 +python-slugify==8.0.4 diff --git a/src/test.py b/src/test.py new file mode 100644 index 0000000..858ae1f --- /dev/null +++ b/src/test.py @@ -0,0 +1,11 @@ +import unittest +import io +import os +from contextlib import redirect_stdout + +from tests.library.dataTest import TestChallenge, TestChallengeFileLoad, TestChallengeFileWrite, TestPage + +if __name__ == '__main__': + + with io.StringIO() as buf, redirect_stdout(buf): + unittest.main() diff --git a/src/tests/data/full-example-multi-flag-object.json b/src/tests/data/full-example-multi-flag-object.json new file mode 100644 index 0000000..7e51ba0 --- /dev/null +++ b/src/tests/data/full-example-multi-flag-object.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json", + "enabled": true, + "name": "Example Challenge Multi Flag", + "slug": "example-challenge-multi-flag", + "author": "Jane Doe", + "category": "crypto", + "difficulty": "medium", + "type": "shared", + "instanced_type": "web", + "flag": [ + { + "flag": "ctfpilot{flag1}", + "case_sensitive": true + }, + { + "flag": "ctfpilot{flag2}" + } + ], + "points": 1000, + "min_points": 100, + "description_location": "demo/description.md", + "prerequisites": [ + "prerequisite-challenge" + ], + "dockerfile_locations": [ + { + "location": "src/web/Dockerfile", + "context": "src/web/", + "identifier": "web" + }, + { + "location": "src/bot/Dockerfile", + "context": "src/bot/", + "identifier": "bot" + } + ], + "handout_dir": "handouts" +} \ No newline at end of file diff --git a/src/tests/data/full-example-multi-flag-object.yml b/src/tests/data/full-example-multi-flag-object.yml new file mode 100644 index 0000000..f73271e --- /dev/null +++ b/src/tests/data/full-example-multi-flag-object.yml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json + +enabled: true +name: "Example Challenge Multi Flag" +slug: "example-challenge-multi-flag" +author: "Jane Doe" +category: "crypto" +difficulty: "medium" +type: shared +instanced_type: "web" +flag: + - flag: "ctfpilot{flag1}" + case_sensitive: true + - flag: "ctfpilot{flag2}" +points: 1000 +min_points: 100 +description_location: "demo/description.md" +prerequisites: + - "prerequisite-challenge" +dockerfile_locations: + - location: "src/web/Dockerfile" + context: "src/web/" + identifier: "web" + - location: "src/bot/Dockerfile" + context: "src/bot/" + identifier: "bot" +handout_dir: "handouts" diff --git a/src/tests/data/full-example-multi-flag.json b/src/tests/data/full-example-multi-flag.json new file mode 100644 index 0000000..cd5aa34 --- /dev/null +++ b/src/tests/data/full-example-multi-flag.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json", + "enabled": true, + "name": "Example Challenge Multi Flag", + "slug": "example-challenge-multi-flag", + "author": "Jane Doe", + "category": "crypto", + "difficulty": "medium", + "type": "shared", + "instanced_type": "web", + "flag": [ + "ctfpilot{flag1}", + "ctfpilot{flag2}" + ], + "points": 1000, + "min_points": 100, + "description_location": "demo/description.md", + "prerequisites": [ + "prerequisite-challenge" + ], + "dockerfile_locations": [ + { + "location": "src/web/Dockerfile", + "context": "src/web/", + "identifier": "web" + }, + { + "location": "src/bot/Dockerfile", + "context": "src/bot/", + "identifier": "bot" + } + ], + "handout_dir": "handouts" +} \ No newline at end of file diff --git a/src/tests/data/full-example-multi-flag.yml b/src/tests/data/full-example-multi-flag.yml new file mode 100644 index 0000000..efda3a5 --- /dev/null +++ b/src/tests/data/full-example-multi-flag.yml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json + +enabled: true +name: "Example Challenge Multi Flag" +slug: "example-challenge-multi-flag" +author: "Jane Doe" +category: "crypto" +difficulty: "medium" +type: shared +instanced_type: "web" +flag: + - "ctfpilot{flag1}" + - "ctfpilot{flag2}" +points: 1000 +min_points: 100 +description_location: "demo/description.md" +prerequisites: + - "prerequisite-challenge" +dockerfile_locations: + - location: "src/web/Dockerfile" + context: "src/web/" + identifier: "web" + - location: "src/bot/Dockerfile" + context: "src/bot/" + identifier: "bot" +handout_dir: "handouts" diff --git a/src/tests/data/full-example.json b/src/tests/data/full-example.json new file mode 100644 index 0000000..539126d --- /dev/null +++ b/src/tests/data/full-example.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json", + "enabled": true, + "name": "Example Challenge", + "slug": "example-challenge", + "author": "John Smith", + "category": "web", + "difficulty": "easy", + "tags": [ + "demo", + "example" + ], + "type": "static", + "instanced_type": "none", + "connection": "nc example.com 1337", + "flag": "ctfpilot{flag}", + "points": 500, + "decay": 100, + "min_points": 50, + "description_location": "demo/description.md", + "prerequisites": [ + "prerequisite-challenge" + ], + "dockerfile_locations": [ + { + "location": "src/web/Dockerfile", + "context": "src/web/", + "identifier": "web" + }, + { + "location": "src/bot/Dockerfile", + "context": "src/bot/", + "identifier": "bot" + } + ], + "handout_dir": "handouts" +} \ No newline at end of file diff --git a/src/tests/data/full-example.yaml b/src/tests/data/full-example.yaml new file mode 100644 index 0000000..376946e --- /dev/null +++ b/src/tests/data/full-example.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json + +enabled: true +name: "Example Challenge" +slug: "example-challenge" +author: "John Smith" +category: "web" +difficulty: "easy" +tags: + - "demo" + - "example" +type: "static" +instanced_type: "none" +connection: "nc example.com 1337" +flag: "ctfpilot{flag}" +points: 500 +decay: 100 +min_points: 50 +description_location: "demo/description.md" +prerequisites: + - "prerequisite-challenge" +dockerfile_locations: + - location: "src/web/Dockerfile" + context: "src/web/" + identifier: "web" + - location: "src/bot/Dockerfile" + context: "src/bot/" + identifier: "bot" +handout_dir: "handouts" diff --git a/src/tests/data/full-example.yml b/src/tests/data/full-example.yml new file mode 100644 index 0000000..113b6af --- /dev/null +++ b/src/tests/data/full-example.yml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json + +enabled: true +name: "Example Challenge" +slug: "example-challenge" +author: "John Smith" +category: "web" +difficulty: "easy" +tags: + - "demo" + - "example" +type: "static" +instanced_type: "none" +instanced_subdomains: ["demo"] +flag: "ctfpilot{flag}" +connection: "nc example.com 1337" +points: 500 +decay: 100 +min_points: 50 +description_location: "demo/description.md" +prerequisites: + - "prerequisite-challenge" +dockerfile_locations: + - location: "src/web/Dockerfile" + context: "src/web/" + identifier: "web" + - location: "src/bot/Dockerfile" + context: "src/bot/" + identifier: "bot" +handout_dir: "handouts" diff --git a/src/tests/data/minimal-example.yml b/src/tests/data/minimal-example.yml new file mode 100644 index 0000000..711f4c2 --- /dev/null +++ b/src/tests/data/minimal-example.yml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json + +enabled: true +name: "Example Challenge" +slug: "example-challenge" +author: "John Smith" +category: "web" +difficulty: "easy" +type: "static" +flag: "ctfpilot{flag}" diff --git a/src/tests/library/dataTest.py b/src/tests/library/dataTest.py new file mode 100644 index 0000000..0e05331 --- /dev/null +++ b/src/tests/library/dataTest.py @@ -0,0 +1,1188 @@ +import unittest +import sys +import json + +sys.path.append('..') + +from library.data import DockerfileLocation, Challenge, ChallengeFlag, Page + +class TestChallenge(unittest.TestCase): + def setUp(self): + self.challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + tags=["test", "example"], + difficulty="easy", + type="static", + instanced_type="none", + instanced_subdomains=["demo"], + connection="nc example.com 1337", + flag="ctfpilot{test_flag}", + points=500, + decay=100, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_initialization(self): + self.assertEqual(self.challenge.name, "Test Challenge") + self.assertEqual(self.challenge.slug, "test-challenge") + self.assertEqual(self.challenge.author, "Test Author") + self.assertEqual(self.challenge.category, "web") + self.assertEqual(self.challenge.tags, ["test", "example"]) + self.assertEqual(self.challenge.difficulty, "easy") + self.assertEqual(self.challenge.type, "static") + self.assertEqual(self.challenge.instanced_type, "none") + self.assertEqual(self.challenge.instanced_subdomains, ["demo"]) + self.assertEqual(self.challenge.connection, "nc example.com 1337") + self.assertEqual(self.challenge.flag, [ChallengeFlag(flag='ctfpilot{test_flag}', case_sensitive=False)]) + self.assertEqual(self.challenge.points, 500) + self.assertEqual(self.challenge.decay, 100) + self.assertEqual(self.challenge.min_points, 50) + self.assertEqual(self.challenge.description_location, "description.md") + self.assertEqual(self.challenge.handout_dir, "files") + + def test_setters(self): + self.challenge.set_name("New Name") + self.assertEqual(self.challenge.name, "New Name") + + self.challenge.set_slug("new-slug") + self.assertEqual(self.challenge.slug, "new-slug") + + self.challenge.set_author("New Author") + self.assertEqual(self.challenge.author, "New Author") + + self.challenge.set_category("crypto") + self.assertEqual(self.challenge.category, "crypto") + + self.challenge.set_difficulty("medium") + self.assertEqual(self.challenge.difficulty, "medium") + + self.challenge.set_tags(["new", "tags"]) + self.assertEqual(self.challenge.tags, ["new", "tags"]) + + self.challenge.set_type("shared") + self.assertEqual(self.challenge.type, "shared") + + self.challenge.set_instanced_type("tcp") + self.assertEqual(self.challenge.instanced_type, "tcp") + + self.challenge.set_instanced_name("new-instanced-challenge") + self.assertEqual(self.challenge.instanced_name, "new-instanced-challenge") + + self.challenge.set_instanced_subdomains(["new-demo"]) + self.assertEqual(self.challenge.instanced_subdomains, ["new-demo"]) + + self.challenge.set_connection("nc new.example.com 4444") + self.assertEqual(self.challenge.connection, "nc new.example.com 4444") + + self.challenge.set_flag("ctfpilot{new_flag}") + self.assertEqual(self.challenge.flag, [ChallengeFlag(flag='ctfpilot{new_flag}', case_sensitive=False)]) + + self.challenge.set_points(1000) + self.assertEqual(self.challenge.points, 1000) + + self.challenge.set_decay(50) + self.assertEqual(self.challenge.decay, 50) + + self.challenge.set_min_points(100) + self.assertEqual(self.challenge.min_points, 100) + + self.challenge.set_description_location("new_description.md") + self.assertEqual(self.challenge.description_location, "new_description.md") + + self.challenge.set_handout_dir("new_files") + self.assertEqual(self.challenge.handout_dir, "new_files") + + def test_add_dockerfile_location(self): + dockerfile_location = DockerfileLocation("src/Dockerfile", "src/", "identifier") + self.challenge.add_dockerfile_location([dockerfile_location]) + self.assertEqual(len(self.challenge.dockerfile_locations), 1) + self.assertEqual(self.challenge.dockerfile_locations[0].location, "src/Dockerfile") + + def test_add_prerequisite(self): + self.challenge.add_prerequisite("prerequisite-challenge") + self.assertEqual(len(self.challenge.prerequisites), 1) + self.assertEqual(self.challenge.prerequisites[0], "prerequisite-challenge") + + def test_missing_name(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name=None, + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + decay=100, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_missing_slug(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug=None, + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_missing_author(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author=None, + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_missing_category(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category=None, + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_missing_difficulty(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty=None, + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_missing_type(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type=None, + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_too_long_connection(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + connection="a" * 256, # Exceeding max length of 255 + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_missing_flag(self): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag=None, + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_name(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_slug(self): + # slug field should automatically be slugified + Challenge( + enabled=True, + name="Test Challenge", + slug="Invalid Slug!", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_author(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_none_example_category(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="invalid-category", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_category(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="Invalid-category?", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_difficulty(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="invalid-difficulty", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_type(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="invalid-type", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_tags(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + tags=["invalid tag!", "okay"], + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_flag(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="invalid-flag", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_single_flag(self): + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{test_flag}', case_sensitive=False)]) + + def test_multiple_flags(self): + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag=["ctfpilot{test_flag1}", "ctfpilot{test_flag2}"], + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{test_flag1}', case_sensitive=False), ChallengeFlag(flag='ctfpilot{test_flag2}', case_sensitive=False)]) + + def test_bad_points(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=-1, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_min_points(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=-1, + description_location="description.md", + handout_dir="files" + ) + + def test_missing_decay(self): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_decay(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + decay=-1, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + + def test_bad_description_location(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="invalid_description.txt", + handout_dir="files" + ) + + def test_bad_handout_dir(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="invalid/files/dir!" + ) + + def test_bad_instanced_subdomains(self): + with self.assertRaises(ValueError): + Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + instanced_subdomains=["invalid subdomain!"], + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + +class TestChallengeFileWrite(unittest.TestCase): + def test_str_json_output(self): + schema_url = "http://example.com/schema.json" + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + connection="nc example.com 1337", + flag="ctfpilot{test_flag}", + points=500, + decay=100, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + json_str = challenge.str_json(schema_url) + data = json.loads(json_str) + self.assertEqual(data["$schema"], schema_url) + self.assertEqual(data["name"], "Test Challenge") + self.assertEqual(data["slug"], "test-challenge") + self.assertEqual(data["author"], "Test Author") + self.assertEqual(data["category"], "web") + self.assertEqual(data["difficulty"], "easy") + self.assertEqual(data["type"], "static") + self.assertEqual(data["instanced_type"], "none") + self.assertEqual(data["instanced_subdomains"], []) + self.assertEqual(data["connection"], "nc example.com 1337") + self.assertEqual(data["flag"], [{'case_sensitive': False, 'flag': 'ctfpilot{test_flag}'}]) + self.assertEqual(data["points"], 500) + self.assertEqual(data["decay"], 100) + self.assertEqual(data["min_points"], 50) + self.assertEqual(data["description_location"], "description.md") + self.assertEqual(data["handout_dir"], "files") + + def test_str_json_output_multiple_flags(self): + schema_url = "http://example.com/schema.json" + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag=["ctfpilot{test_flag1}", "ctfpilot{test_flag2}"], + points=500, + decay=100, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + json_str = challenge.str_json(schema_url) + data = json.loads(json_str) + self.assertEqual(data["$schema"], schema_url) + self.assertEqual(data["name"], "Test Challenge") + self.assertEqual(data["slug"], "test-challenge") + self.assertEqual(data["author"], "Test Author") + self.assertEqual(data["category"], "web") + self.assertEqual(data["difficulty"], "easy") + self.assertEqual(data["type"], "static") + self.assertEqual(data["instanced_type"], "none") + self.assertEqual(data["connection"], None) + self.assertEqual(data["flag"], [{'case_sensitive': False, 'flag': 'ctfpilot{test_flag1}'}, {'case_sensitive': False, 'flag': 'ctfpilot{test_flag2}'}]) + self.assertEqual(data["points"], 500) + self.assertEqual(data["decay"], 100) + self.assertEqual(data["min_points"], 50) + self.assertEqual(data["description_location"], "description.md") + self.assertEqual(data["handout_dir"], "files") + + def test_str_json_output_multiple_flag_objects(self): + schema_url = "http://example.com/schema.json" + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag=[ChallengeFlag(flag='ctfpilot{test_flag1}', case_sensitive=True), ChallengeFlag(flag='ctfpilot{test_flag2}', case_sensitive=False)], + points=500, + decay=100, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + json_str = challenge.str_json(schema_url) + data = json.loads(json_str) + self.assertEqual(data["$schema"], schema_url) + self.assertEqual(data["name"], "Test Challenge") + self.assertEqual(data["slug"], "test-challenge") + self.assertEqual(data["author"], "Test Author") + self.assertEqual(data["category"], "web") + self.assertEqual(data["difficulty"], "easy") + self.assertEqual(data["type"], "static") + self.assertEqual(data["instanced_type"], "none") + self.assertEqual(data["connection"], None) + self.assertEqual(data["flag"], [{'case_sensitive': True, 'flag': 'ctfpilot{test_flag1}'}, {'case_sensitive': False, 'flag': 'ctfpilot{test_flag2}'}]) + self.assertEqual(data["points"], 500) + self.assertEqual(data["decay"], 100) + self.assertEqual(data["min_points"], 50) + self.assertEqual(data["description_location"], "description.md") + self.assertEqual(data["handout_dir"], "files") + + def test_str_json_output_special_chars(self): + schema_url = "http://example.com/schema.json" + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + tags=["test", "example"], + type="static", + instanced_type="none", + flag="ctfpilot{test_flag'`\"}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + json_str = challenge.str_json(schema_url) + data = json.loads(json_str) + self.assertEqual(data["$schema"], schema_url) + self.assertEqual(data["name"], "Test Challenge") + self.assertEqual(data["slug"], "test-challenge") + self.assertEqual(data["author"], "Test Author") + self.assertEqual(data["category"], "web") + self.assertEqual(data["difficulty"], "easy") + self.assertEqual(data["tags"], ["test", "example"]) + self.assertEqual(data["type"], "static") + self.assertEqual(data["instanced_type"], "none") + self.assertEqual(data["connection"], None) + self.assertEqual(data["flag"], [{'case_sensitive': False, 'flag': 'ctfpilot{test_flag\'`\"}'}]) + self.assertEqual(data["points"], 500) + self.assertEqual(data["decay"], 75) + self.assertEqual(data["min_points"], 50) + self.assertEqual(data["description_location"], "description.md") + self.assertEqual(data["handout_dir"], "files") + + def test_str_yml_output(self): + schema_url = "http://example.com/schema.json" + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + tags=["test", "example"], + type="static", + instanced_type="none", + connection="nc example.com 1337", + flag="ctfpilot{test_flag}", + points=500, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + yml_str = challenge.str_yml(schema_url) + self.assertTrue(yml_str.startswith(f"# yaml-language-server: $schema={schema_url}")) + self.assertIn("name: Test Challenge", yml_str) + self.assertIn("slug: test-challenge", yml_str) + self.assertIn("author: Test Author", yml_str) + self.assertIn("category: web", yml_str) + self.assertIn("difficulty: easy", yml_str) + self.assertIn("tags:", yml_str) + self.assertIn("- test", yml_str) + self.assertIn("- example", yml_str) + self.assertIn("type: static", yml_str) + self.assertIn("instanced_type: none", yml_str) + self.assertIn("connection: nc example.com 1337", yml_str) + self.assertIn("flag:", yml_str) + self.assertIn("- flag: ctfpilot{test_flag}", yml_str) + self.assertIn(" case_sensitive: false", yml_str) + self.assertIn("points: 500", yml_str) + self.assertIn("decay: 75", yml_str) + self.assertIn("min_points: 50", yml_str) + self.assertIn("description_location: description.md", yml_str) + self.assertIn("handout_dir: files", yml_str) + + def test_str_yml_output_multiple_flags(self): + schema_url = "http://example.com/schema.json" + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag=["ctfpilot{test_flag1}", "ctfpilot{test_flag2}"], + points=500, + decay=100, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + yml_str = challenge.str_yml(schema_url) + self.assertTrue(yml_str.startswith(f"# yaml-language-server: $schema={schema_url}")) + self.assertIn("name: Test Challenge", yml_str) + self.assertIn("slug: test-challenge", yml_str) + self.assertIn("author: Test Author", yml_str) + self.assertIn("category: web", yml_str) + self.assertIn("difficulty: easy", yml_str) + self.assertIn("type: static", yml_str) + self.assertIn("instanced_type: none", yml_str) + self.assertIn("flag:", yml_str) + self.assertIn("- flag: ctfpilot{test_flag1}", yml_str) + self.assertIn(" case_sensitive: false", yml_str) + self.assertIn("- flag: ctfpilot{test_flag2}", yml_str) + self.assertIn(" case_sensitive: false", yml_str) + self.assertIn("points: 500", yml_str) + self.assertIn("decay: 100", yml_str) + self.assertIn("min_points: 50", yml_str) + self.assertIn("description_location: description.md", yml_str) + self.assertIn("handout_dir: files", yml_str) + + def test_str_yml_output_multiple_flag_objects(self): + schema_url = "http://example.com/schema.json" + challenge = Challenge( + enabled=True, + name="Test Challenge", + slug="test-challenge", + author="Test Author", + category="web", + difficulty="easy", + type="static", + instanced_type="none", + flag=[ChallengeFlag(flag='ctfpilot{test_flag1}', case_sensitive=True), ChallengeFlag(flag='ctfpilot{test_flag2}', case_sensitive=False)], + points=500, + decay=100, + min_points=50, + description_location="description.md", + handout_dir="files" + ) + yml_str = challenge.str_yml(schema_url) + self.assertTrue(yml_str.startswith(f"# yaml-language-server: $schema={schema_url}")) + self.assertIn("name: Test Challenge", yml_str) + self.assertIn("slug: test-challenge", yml_str) + self.assertIn("author: Test Author", yml_str) + self.assertIn("category: web", yml_str) + self.assertIn("difficulty: easy", yml_str) + self.assertIn("type: static", yml_str) + self.assertIn("instanced_type: none", yml_str) + self.assertIn("flag:", yml_str) + self.assertIn("- flag: ctfpilot{test_flag1}", yml_str) + self.assertIn(" case_sensitive: true", yml_str) + self.assertIn("- flag: ctfpilot{test_flag2}", yml_str) + self.assertIn(" case_sensitive: false", yml_str) + self.assertIn("points: 500", yml_str) + self.assertIn("decay: 100", yml_str) + self.assertIn("min_points: 50", yml_str) + self.assertIn("description_location: description.md", yml_str) + self.assertIn("handout_dir: files", yml_str) + +class TestChallengeFileLoad(unittest.TestCase): + file_dir = 'tests/data' + json_file = 'full-example.json' + json_multi_flag_file = 'full-example-multi-flag.json' + json_multi_flag_object_file = 'full-example-multi-flag-object.json' + yml_file = 'full-example.yml' + yml_multi_flag_file = 'full-example-multi-flag.yml' + yml_multi_flag_object_file = 'full-example-multi-flag-object.yml' + yaml_file = 'full-example.yaml' + minimal_example_file = 'minimal-example.yml' + + def test_load_json(self): + challenge = Challenge.load(f'{self.file_dir}/{self.json_file}') + self.assertEqual(challenge.name, "Example Challenge") + self.assertEqual(challenge.slug, "example-challenge") + self.assertEqual(challenge.author, "John Smith") + self.assertEqual(challenge.category, "web") + self.assertEqual(challenge.difficulty, "easy") + self.assertEqual(challenge.tags, ["demo", "example"]) + self.assertEqual(challenge.type, "static") + self.assertEqual(challenge.instanced_type, "none") + self.assertEqual(challenge.connection, "nc example.com 1337") + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{flag}', case_sensitive=False)]) + self.assertEqual(challenge.points, 500) + self.assertEqual(challenge.decay, 100) + self.assertEqual(challenge.min_points, 50) + self.assertEqual(challenge.description_location, "demo/description.md") + self.assertEqual(challenge.handout_dir, "handouts") + + def test_load_json_multi_flag(self): + challenge = Challenge.load(f'{self.file_dir}/{self.json_multi_flag_file}') + self.assertEqual(challenge.name, "Example Challenge Multi Flag") + self.assertEqual(challenge.slug, "example-challenge-multi-flag") + self.assertEqual(challenge.author, "Jane Doe") + self.assertEqual(challenge.category, "crypto") + self.assertEqual(challenge.difficulty, "medium") + self.assertEqual(challenge.tags, []) + self.assertEqual(challenge.type, "shared") + self.assertEqual(challenge.instanced_type, "web") + self.assertEqual(challenge.connection, None) + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{flag1}', case_sensitive=False), ChallengeFlag(flag='ctfpilot{flag2}', case_sensitive=False)]) + self.assertEqual(challenge.points, 1000) + self.assertEqual(challenge.decay, 75) + self.assertEqual(challenge.min_points, 100) + self.assertEqual(challenge.description_location, "demo/description.md") + self.assertEqual(challenge.handout_dir, "handouts") + + def test_load_json_multi_flag_object(self): + challenge = Challenge.load(f'{self.file_dir}/{self.json_multi_flag_object_file}') + self.assertEqual(challenge.name, "Example Challenge Multi Flag") + self.assertEqual(challenge.slug, "example-challenge-multi-flag") + self.assertEqual(challenge.author, "Jane Doe") + self.assertEqual(challenge.category, "crypto") + self.assertEqual(challenge.difficulty, "medium") + self.assertEqual(challenge.tags, []) + self.assertEqual(challenge.type, "shared") + self.assertEqual(challenge.instanced_type, "web") + self.assertEqual(challenge.connection, None) + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{flag1}', case_sensitive=True), ChallengeFlag(flag='ctfpilot{flag2}', case_sensitive=False)]) + self.assertEqual(challenge.points, 1000) + self.assertEqual(challenge.decay, 75) + self.assertEqual(challenge.min_points, 100) + self.assertEqual(challenge.description_location, "demo/description.md") + self.assertEqual(challenge.handout_dir, "handouts") + + def test_load_yml(self): + challenge = Challenge.load(f'{self.file_dir}/{self.yml_file}') + self.assertEqual(challenge.name, "Example Challenge") + self.assertEqual(challenge.slug, "example-challenge") + self.assertEqual(challenge.author, "John Smith") + self.assertEqual(challenge.category, "web") + self.assertEqual(challenge.difficulty, "easy") + self.assertEqual(challenge.tags, ["demo", "example"]) + self.assertEqual(challenge.type, "static") + self.assertEqual(challenge.instanced_type, "none") + self.assertEqual(challenge.instanced_subdomains, ["demo"]) + self.assertEqual(challenge.connection, "nc example.com 1337") + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{flag}', case_sensitive=False)]) + self.assertEqual(challenge.points, 500) + self.assertEqual(challenge.decay, 100) + self.assertEqual(challenge.min_points, 50) + self.assertEqual(challenge.description_location, "demo/description.md") + self.assertEqual(challenge.handout_dir, "handouts") + + def test_load_yml_multi_flag(self): + challenge = Challenge.load(f'{self.file_dir}/{self.yml_multi_flag_file}') + self.assertEqual(challenge.name, "Example Challenge Multi Flag") + self.assertEqual(challenge.slug, "example-challenge-multi-flag") + self.assertEqual(challenge.author, "Jane Doe") + self.assertEqual(challenge.category, "crypto") + self.assertEqual(challenge.difficulty, "medium") + self.assertEqual(challenge.tags, []) + self.assertEqual(challenge.type, "shared") + self.assertEqual(challenge.instanced_type, "web") + self.assertEqual(challenge.connection, None) + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{flag1}', case_sensitive=False), ChallengeFlag(flag='ctfpilot{flag2}', case_sensitive=False)]) + self.assertEqual(challenge.points, 1000) + self.assertEqual(challenge.decay, 75) + self.assertEqual(challenge.min_points, 100) + self.assertEqual(challenge.description_location, "demo/description.md") + self.assertEqual(challenge.handout_dir, "handouts") + + def test_load_yml_multi_flag_object(self): + challenge = Challenge.load(f'{self.file_dir}/{self.yml_multi_flag_object_file}') + self.assertEqual(challenge.name, "Example Challenge Multi Flag") + self.assertEqual(challenge.slug, "example-challenge-multi-flag") + self.assertEqual(challenge.author, "Jane Doe") + self.assertEqual(challenge.category, "crypto") + self.assertEqual(challenge.difficulty, "medium") + self.assertEqual(challenge.tags, []) + self.assertEqual(challenge.type, "shared") + self.assertEqual(challenge.instanced_type, "web") + self.assertEqual(challenge.connection, None) + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{flag1}', case_sensitive=True), ChallengeFlag(flag='ctfpilot{flag2}', case_sensitive=False)]) + self.assertEqual(challenge.points, 1000) + self.assertEqual(challenge.decay, 75) + self.assertEqual(challenge.min_points, 100) + self.assertEqual(challenge.description_location, "demo/description.md") + self.assertEqual(challenge.handout_dir, "handouts") + + def test_load_yaml(self): + challenge = Challenge.load(f'{self.file_dir}/{self.yaml_file}') + self.assertEqual(challenge.name, "Example Challenge") + self.assertEqual(challenge.slug, "example-challenge") + self.assertEqual(challenge.author, "John Smith") + self.assertEqual(challenge.category, "web") + self.assertEqual(challenge.difficulty, "easy") + self.assertEqual(challenge.tags, ["demo", "example"]) + self.assertEqual(challenge.type, "static") + self.assertEqual(challenge.instanced_type, "none") + self.assertEqual(challenge.instanced_subdomains, []) + self.assertEqual(challenge.connection, "nc example.com 1337") + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{flag}', case_sensitive=False)]) + self.assertEqual(challenge.points, 500) + self.assertEqual(challenge.min_points, 50) + self.assertEqual(challenge.description_location, "demo/description.md") + self.assertEqual(challenge.handout_dir, "handouts") + + def test_load_minimal_example(self): + challenge = Challenge.load(f'{self.file_dir}/{self.minimal_example_file}') + self.assertEqual(challenge.name, "Example Challenge") + self.assertEqual(challenge.slug, "example-challenge") + self.assertEqual(challenge.author, "John Smith") + self.assertEqual(challenge.category, "web") + self.assertEqual(challenge.difficulty, "easy") + self.assertEqual(challenge.tags, []) + self.assertEqual(challenge.type, "static") + self.assertEqual(challenge.instanced_type, "none") + self.assertEqual(challenge.connection, None) + self.assertEqual(challenge.flag, [ChallengeFlag(flag='ctfpilot{flag}', case_sensitive=False)]) + self.assertEqual(challenge.points, 1000) + self.assertEqual(challenge.decay, 75) + self.assertEqual(challenge.min_points, 100) + self.assertEqual(challenge.description_location, "description.md") + self.assertEqual(challenge.handout_dir, "handout") + + def test_bad_file(self): + with self.assertRaises(FileNotFoundError): + Challenge.load(f'{self.file_dir}/invalid_challenge.json') + + def test_bad_file_extension(self): + with self.assertRaises(ValueError): + Challenge.load(f'{self.file_dir}/invalid_challenge.txt') + +class TestPage(unittest.TestCase): + + def test_page_initialization(self): + page = Page( + enabled=True, + slug="example-page", + title="Example Page", + route="/example-page", + content="example.md", + format="markdown", + auth=True, + draft=False + ) + self.assertTrue(page.enabled) + self.assertEqual(page.slug, "example-page") + self.assertEqual(page.title, "Example Page") + self.assertEqual(page.route, "/example-page") + self.assertEqual(page.content, "example.md") + self.assertEqual(page.format, "markdown") + self.assertTrue(page.auth) + self.assertFalse(page.draft) + + def test_page_setters(self): + page = Page() + page.set_slug("new-page") + self.assertEqual(page.slug, "new-page") + + page.set_title("New Page Title") + self.assertEqual(page.title, "New Page Title") + + page.set_route("/new-page") + self.assertEqual(page.route, "/new-page") + + page.set_content("new-content.md") + self.assertEqual(page.content, "new-content.md") + + page.set_format("html") + self.assertEqual(page.format, "html") + + page.set_auth(True) + self.assertTrue(page.auth) + + page.set_draft(True) + self.assertTrue(page.draft) + + def test_page_invalid_slug(self): + with self.assertRaises(ValueError): + Page(slug="") + + def test_page_invalid_title(self): + with self.assertRaises(ValueError): + Page(title="") + + def test_page_invalid_route(self): + with self.assertRaises(ValueError): + Page(route="") + + def test_page_invalid_content(self): + with self.assertRaises(ValueError): + Page(content="invalid-content") + + def test_page_invalid_format(self): + with self.assertRaises(ValueError): + Page(format="invalid-format") + + def test_page_generate_dict(self): + page = Page( + enabled=True, + slug="example-page", + title="Example Page", + route="/example-page", + content="example.md", + format="markdown", + auth=True, + draft=False + ) + schema_location = "http://example.com/schema.json" + page_dict = page.generate_dict(schema_location) + self.assertEqual(page_dict["$schema"], schema_location) + self.assertTrue(page_dict["enabled"]) + self.assertEqual(page_dict["slug"], "example-page") + self.assertEqual(page_dict["title"], "Example Page") + self.assertEqual(page_dict["route"], "/example-page") + self.assertEqual(page_dict["content"], "example.md") + self.assertEqual(page_dict["format"], "markdown") + self.assertTrue(page_dict["auth"]) + self.assertFalse(page_dict["draft"]) + + def test_page_str_json_output(self): + schema_url = "http://example.com/schema.json" + page = Page( + enabled=True, + slug="example-page", + title="Example Page", + route="/example-page", + content="example.md", + format="markdown", + auth=True, + draft=False + ) + json_str = page.str_json(schema_url) + data = json.loads(json_str) + self.assertEqual(data["$schema"], schema_url) + self.assertEqual(data["slug"], "example-page") + self.assertEqual(data["title"], "Example Page") + self.assertEqual(data["route"], "/example-page") + self.assertEqual(data["content"], "example.md") + self.assertEqual(data["format"], "markdown") + self.assertTrue(data["auth"]) + self.assertFalse(data["draft"]) + + def test_page_str_yml_output(self): + schema_url = "http://example.com/schema.json" + page = Page( + enabled=True, + slug="example-page", + title="Example Page", + route="/example-page", + content="example.md", + format="markdown", + auth=True, + draft=False + ) + yml_str = page.str_yml(schema_url) + self.assertTrue(yml_str.startswith(f"# yaml-language-server: $schema={schema_url}")) + self.assertIn("slug: example-page", yml_str) + self.assertIn("title: Example Page", yml_str) + self.assertIn("route: /example-page", yml_str) + self.assertIn("content: example.md", yml_str) + self.assertIn("format: markdown", yml_str) + self.assertIn("auth: true", yml_str) + self.assertIn("draft: false", yml_str) + + def test_page_load_from_json(self): + json_data = { + "enabled": True, + "slug": "example-page", + "title": "Example Page", + "route": "/example-page", + "content": "example.md", + "format": "markdown", + "auth": True, + "draft": False + } + page = Page.load_from_json(json_data) + self.assertTrue(page.enabled) + self.assertEqual(page.slug, "example-page") + self.assertEqual(page.title, "Example Page") + self.assertEqual(page.route, "/example-page") + self.assertEqual(page.content, "example.md") + self.assertEqual(page.format, "markdown") + self.assertTrue(page.auth) + self.assertFalse(page.draft) + + def test_page_load_from_yaml(self): + yaml_data = { + "enabled": True, + "slug": "example-page", + "title": "Example Page", + "route": "/example-page", + "content": "example.md", + "format": "markdown", + "auth": True, + "draft": False + } + page = Page.load_from_yaml(yaml_data) + self.assertTrue(page.enabled) + self.assertEqual(page.slug, "example-page") + self.assertEqual(page.title, "Example Page") + self.assertEqual(page.route, "/example-page") + self.assertEqual(page.content, "example.md") + self.assertEqual(page.format, "markdown") + self.assertTrue(page.auth) + self.assertFalse(page.draft) + +if __name__ == '__main__': + print("Tests cannot be run directly. Please run test.py") \ No newline at end of file From 4f780d1b310b0034496372e9b590affcfcd37ce4 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Mon, 1 Dec 2025 22:30:09 +0100 Subject: [PATCH 02/26] Update README and refactor command arguments for improved usability - Enhanced README.md with detailed instructions on running the tool, including cloning the repository and installing dependencies. - Updated command argument parsing in `page.py`, `slugify.py`, and `template_renderer.py` to ensure GitHub repository is provided, improving error handling. - Replaced hardcoded schema URLs with constants from `config.py` for better maintainability. - Removed unused demo and model files to clean up the repository. --- README.md | 222 +++++++++++++++++++-- src/commands/page.py | 12 +- src/commands/slugify.py | 4 +- src/commands/template_renderer.py | 11 +- src/demo.py | 200 ------------------- src/library/config.py | 8 + src/library/generator.py | 5 +- src/library/utils.py | 22 ++- src/models/__init__.py | 0 src/models/challenge.py | 307 ------------------------------ src/models/page.py | 80 -------- 11 files changed, 245 insertions(+), 626 deletions(-) delete mode 100644 src/demo.py delete mode 100644 src/models/__init__.py delete mode 100644 src/models/challenge.py delete mode 100644 src/models/page.py diff --git a/README.md b/README.md index 4b69d27..acf4e6c 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,212 @@ Challenge Toolkit for CTF Pilot. Allows for bootstrapping challenges and pipeline actions on challenges. -### Code gen +## How to run + +> [!NOTE] +> We are currently working on making it easier to use the tool. + +The current tool is only provided as the raw python files. +Therefore, in order to run the tool, first clone this repository: + +```sh +git clone https://github.com/ctfpilot/challenge-toolkit +``` + +In order to install required dependencies, run: + +```sh +pip install -r challenge-toolkit/src/requirements.txt +``` + +> [!IMPORTANT] +> The tool assumes, that the current working directory is the root of a challenge repository. +> Read more about the expected structure of a challenge repository in the **[Challenge repository structure documentation](#challenge-repository-structure)** section. + +You can then run the tool using python: ```sh -datamodel-codegen \ - --url https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json \ - --output ./models/challenge.py \ - --use-annotated \ - --use-generic-container-types \ - --use-field-description \ - --output-model-type pydantic_v2.BaseModel \ - --set-default-enum-member - -datamodel-codegen \ - --url https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json \ - --output ./models/page.py \ - --use-annotated \ - --use-generic-container-types \ - --use-field-description \ - --output-model-type pydantic_v2.BaseModel \ - --set-default-enum-member +python challenge-toolkit/src/ctf.py [options] +``` + +### Dependencies + +Currently, the following dependencies are required: + +- Python 3.8 or higher +- `pyyaml` Python package +- `python-slugify` Python package + +`pyyaml` and `python-slugify` are defined in the `requirements.txt` file. + +### Including it in your project as a git submodule + +One way to include it into your own project is to add it as a git submodule: + +```sh +git submodule add https://github.com/ctfpilot/challenge-toolkit +``` + +To then clone your own project with the submodule included, run: + +```sh +git clone --recurse-submodules +``` + +Or if you already have cloned your repository, run: + +```sh +git submodule update --init --recursive +``` + +## Commands + +The tool provides the following commands: + +| Command | Description | +|-----------|--------------------------------------------------| +| `create` | Create a new challenge based on the template. | +| `pipeline`| Run the pipeline actions on all challenges. | + +## Challenge repository structure + +> [!IMPORTANT] +> The tool works based on the Challenge repository structure defined below. Working outside a structure like this is not supported. + +The tools expect a specific directory structure, where challenges are stored in a `challenges` directory. +Inside the `challenges` directory, challenges are divided into categories. +Each challenge is stored in its own directory, named identically to the challenge slug. + +Besides the `challenges` directory, there is also a `template` directory, which contains the base templates for kubernetes deployment files. + +The structure is as follows: + +```txt +. +├── challenges/ +│ ├── web +│ ├── forensics +│ ├── rev +│ ├── crypto +│ ├── pwn +│ ├── boot2root +│ ├── osint +│ ├── misc +│ ├── blockchain +│ └── beginner/ +│ └── challenge-1 +├── template/ +├── challenge-toolkit/ +└── +``` + + +### Challenge structure + +> [!TIP] +> Challenge source code is located in the `src/` directory. +> The main files are `challenge.yml`, `description.md` and `README.md`, along with the `src/` directories. + +Each challenge is stored in its own directory, named after the challenge slug. +Within the challenge directory, there are several subdirectories and files that make up the challenge. + +The subdirectory structure of a challenge is as follows: + +```txt +. +├── handout/ +├── k8s/ +├── solution/ +├── src/ +├── template/ +├── challenge.yml +├── description.md +├── README.md +└── version +``` + +- `handout/`contains the files that are handed out to the user. This may be the binary that needs to be reversed, the pcap file that needs to be analyzed, etc. The files in this directory are automatically zipped and stored in the `k8s/files/` directory as `_.zip`. +- `k8s/` contains the kubernetes deployment files for the challenge. This is automatically generated and used for deploying to the CTF platform. This directory should not be modified manually, but instead use the `challenge.yml` file to specify the deployment files. +- `solution/` contains the script that is used to solve the challenge. This is filled out by the challenge creator. No further standard for the content is enforced. +- `src/` contains the source code for the challenge. It contains all the code needed for running the challenge. It may also contain any copies that needs to be handed out. Dockerfiles, python scripts, etc. lives here. +- `template/` contains the template files for the challenge. For example the kubernetes deployment files, or similar, that are rendered with the data from the `challenge.yml` file. +- `challenge.yml` contains the metadata for the challenge. This must be filled out by the challenge creator. Follows a very strict structure, which can be found in the schema file provided in the file. + The file may be replaced by a JSON file, as `challenge.json`. +- `description.md` contains the description of the challenge. This is the text that is shown to the user, when they open the challenge. It should be written in markdown. +- `README.md` contains the base idea and informationm of the challenge. May contain inspiration or other internal notes about the challenge. May also contain solution steps. +- `version` contains the version of the challenge. This is automatically updated by the `pipeline` command. Contains a single number, which is the version number of the challenge. + +To learn more about the `challenge.yml` file, see the [CTF Pilot's Challenge Schema](https://github.com/ctfpilot/challenge-schema). + +### Challenges with Dockerfiles + +It is very common to use Docker for challenges, as it is the core for shared and instanced challenges. + +Docker images are built using the `pipeline` command. +They are built based on the Dockerfiles provided in the `challenge.yml` file. +Each Dockerfile location is relative to the individual challenge directory. + +The following should be described in the `challenge.yml` file for dockerfiles, under the `dockerfile_locations` key: + +- `location`: The location of the Dockerfile relative to the challenge directory. Example: `src/Dockerfile`. +- `context`: The context of the Dockerfile relative to the challenge directory. Example: `src/`. + Context controls where Docker looks for files to include in the build process. +- `identifier`: The identifier of the Dockerfile to suffix the docker image with. Example: `web`, `db`, `app`, `bot`. + The identifier is used when multiple Docker images are needed for a challenge. + *This may be left out, if only a single Dockerfile is described.* + +This format follows the [CTF Pilot's Challenge Schema](https://github.com/ctfpilot/challenge-schema). + +
+ Click to expand example + + +An example of multiple Dockerfiles, with one for the app and one for the database: + +```yaml +dockerfile_locations: + - location: src/app/Dockerfile + context: src/app/ + identifier: app + - location: src/db/Dockerfile + context: src/db/ + identifier: db +``` + +The folder structure for this example would be: + +```txt +. +└── src/ + ├── app/ + │ ├── Dockerfile + │ └── + └── db/ + ├── Dockerfile + └── +``` + +
+
+ +The `pipeline` command tags docker images with the following format: + +```txt +ghcr.io/---: +``` + +Examples of docker image tags: + +With identifier: + +```txt +ghcr.io/ctfpilot/example-web-challenge-1-web:latest +``` + +Without identifier: + +```txt +ghcr.io/ctfpilot/example-web-challenge-1:latest ``` ## Contributing diff --git a/src/commands/page.py b/src/commands/page.py index d649941..5043228 100644 --- a/src/commands/page.py +++ b/src/commands/page.py @@ -7,6 +7,7 @@ from library.utils import Utils from library.data import Page from library.generator import Generator +from library.config import PAGE_SCHEMA class Args: args = None @@ -18,12 +19,11 @@ def __init__(self, parent_parser = None): if parent_parser: self.subcommand = True self.parser = parent_parser.add_parser("page", help="Render template for CTFd pages") - self.parser = parent_parser.add_parser("repo", help="GitHub repository for CTFd pages in the format 'owner/repo'") else: self.parser = argparse.ArgumentParser(description="Render template for CTFd pages") self.parser.add_argument("page", help="Page to render (directory for page - 'web/example')") - self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY ", "ctfpilot/example")) + self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY ", "")) def parse(self): if self.subcommand: @@ -44,7 +44,11 @@ def parse(self): sys.exit(1) self.page = page - self.repo = self.args.repo or os.getenv("GITHUB_REPOSITORY", "ctfpilot/example") + self.repo = self.args.repo or os.getenv("GITHUB_REPOSITORY", "") + + if not self.repo or self.repo.strip() == "": + print("GitHub repository is required. Please provide it via the --repo argument or the GITHUB_REPOSITORY environment variable.") + sys.exit(1) def __getattr__(self, name): return getattr(self.args, name) @@ -74,7 +78,7 @@ def replace_templated(key: str, value: str, content: str): return content def get_template_content(self): - template_source = self.page.str_json("https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json") + template_source = self.page.str_json(PAGE_SCHEMA) # Iterate over each line in the source, and indent it template_source_indented = "".join([" " + line + "\n" for line in template_source.splitlines()]) diff --git a/src/commands/slugify.py b/src/commands/slugify.py index 861fd5f..86e6f7e 100644 --- a/src/commands/slugify.py +++ b/src/commands/slugify.py @@ -8,7 +8,7 @@ class Args: args = None - slug: str = None + slug: str = "" subcommand = False def __init__(self, parent_parser = None): @@ -38,7 +38,7 @@ def run(name: str) -> str: """ Slugify a string for use in challenge slug. """ - return Utils.slugify(name) + return Utils.slugify(name) or "" class SlugifyCommand: args = None diff --git a/src/commands/template_renderer.py b/src/commands/template_renderer.py index 5fea936..a66a748 100644 --- a/src/commands/template_renderer.py +++ b/src/commands/template_renderer.py @@ -9,6 +9,7 @@ from library.utils import Utils from library.data import Challenge from library.generator import Generator +from library.config import CHALLENGE_SCHEMA class Args: args = None @@ -29,7 +30,7 @@ def __init__(self, parent_parser = None): self.parser.add_argument("challenge", help="Challenge to run (directory for challenge - 'web/example')") self.parser.add_argument("--expires", help="Time until challenge expires", type=int, default=3600) self.parser.add_argument("--available", help="Time until challenge is available", type=int, default=0) - self.parser.add_argument("--repo", help="GitHub repository for CTFd challenges in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY ", "ctfpilot/example")) + self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY ", "")) def parse(self): if self.subcommand: @@ -53,7 +54,11 @@ def parse(self): self.expires = self.args.expires self.available = self.args.available - self.repo = self.args.repo or os.getenv("GITHUB_REPOSITORY", "ctfpilot/example") + self.repo = self.args.repo or os.getenv("GITHUB_REPOSITORY", "") + + if not self.repo or self.repo.strip() == "": + print("GitHub repository is required. Please provide it via the --repo argument or the GITHUB_REPOSITORY environment variable.") + sys.exit(1) def __getattr__(self, name): return getattr(self.args, name) @@ -204,7 +209,7 @@ def replace_templated(self, key: str, value: str, content: str): return content def get_template_content(self): - template_source = self.challenge.str_json("https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json") + template_source = self.challenge.str_json(CHALLENGE_SCHEMA) # Iterate over each line in the source, and indent it template_source_indented = "".join([" " + line + "\n" for line in template_source.splitlines()]) diff --git a/src/demo.py b/src/demo.py deleted file mode 100644 index 54221b3..0000000 --- a/src/demo.py +++ /dev/null @@ -1,200 +0,0 @@ -# generated by datamodel-codegen: -# filename: https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json -# timestamp: 2025-11-26T22:22:46+00:00 - -from __future__ import annotations - -from enum import Enum -from typing import List, Optional, Union - -from pydantic import BaseModel, Field, conint, constr - - -class Prerequisite(BaseModel): - __root__: constr(regex=r'^[a-z0-9-]+$', min_length=1, max_length=100) = Field( - ..., - description='The prerequisites of the challenge. Must be the slugs of the challenges.', - ) - - -class Category(Enum): - web = 'web' - forensics = 'forensics' - rev = 'rev' - crypto = 'crypto' - pwn = 'pwn' - boot2root = 'boot2root' - osint = 'osint' - misc = 'misc' - blockchain = 'blockchain' - mobile = 'mobile' - test = 'test' - - -class Difficulty(Enum): - beginner = 'beginner' - easy = 'easy' - easy_medium = 'easy-medium' - medium = 'medium' - medium_hard = 'medium-hard' - hard = 'hard' - very_hard = 'very-hard' - insane = 'insane' - - -class Type(Enum): - static = 'static' - shared = 'shared' - instanced = 'instanced' - - -class InstancedType(Enum): - web = 'web' - tcp = 'tcp' - none = 'none' - - -class InstancedSubdomain(BaseModel): - __root__: constr( - regex=r'^((web|tcp):)?[a-z0-9-]+$', min_length=0, max_length=10 - ) = Field( - ..., - description='The subdomain of the instanced challenge. This is applicable if the `type` is `instanced` and the challenge is hosted on a server. It will be prefixed with the instanced domain. Each entry may optionally be prefixed with `web:` or `tcp:` (e.g., `web:api`, `tcp:service`, or just `admin`). The prefix indicates the protocol associated with the subdomain.', - examples=['web', 'admin', 'api', 'tcp:service', 'web:dashboard'], - ) - - -class Flag(BaseModel): - flag: constr( - regex=r'^(\w{2,10}\{[^}]*\}|dynamic|null)$', min_length=4, max_length=1000 - ) - case_sensitive: Optional[bool] = Field( - True, - description='Whether the flag is case sensitive or not. If false, the flag will be checked case insensitively. Default is true.', - examples=[True, False], - ) - - -class DockerfileLocation(BaseModel): - location: constr(regex=r'^[a-zA-Z0-9-_/.]+$') = Field( - ..., - description='The location of the Dockerfile relative to the challenge directory', - examples=['src/Dockerfile'], - ) - context: constr(regex=r'^[a-zA-Z0-9-_/.]+$') = Field( - ..., - description='The context of the Dockerfile relative to the challenge directory', - examples=['src/'], - ) - identifier: Optional[str] = Field( - None, - description='The identifier of the Dockerfile to suffix the docker image with (None, null, or empty string for no suffix)', - examples=['None', None], - ) - - -class Model(BaseModel): - version: Optional[constr(regex=r'^(\d+\.)?(\d+\.)?(\*|\d+)$')] = Field( - '1', - description='The version of the challenge schema', - examples=['1', '1.0', '1.0.0'], - ) - enabled: bool = Field( - ..., - description='Whether the challenge is enabled or not. If disabled, this forces the challenge to not be deployed or interacted with.\nShould only be false if the challenge is not ready.', - ) - name: constr(min_length=1, max_length=50) = Field( - ..., description='The name of the challenge', examples=['Example Challenge'] - ) - slug: constr(regex=r'^[a-z0-9-]+$', min_length=1, max_length=50) = Field( - ..., description='The slug of the challenge', examples=['example-challenge'] - ) - author: constr(min_length=1, max_length=100) = Field( - ..., description='The author of the challenge', examples=['John Smith'] - ) - tags: Optional[ - List[constr(regex=r'^[a-zA-Z0-9-_:;? ]+$', min_length=1, max_length=50)] - ] = Field( - [], description='Tags for the challenge. Used for filtering and searching.' - ) - prerequisites: Optional[List[Prerequisite]] = Field( - None, - description='Required challenges to solve before this challenge can be displayed', - unique_items=True, - ) - category: Category = Field(..., description='The category of the challenge') - difficulty: Difficulty = Field(..., description='The difficulty of the challenge') - type: Type = Field( - ..., - description='The type of challenge.\nStatic challenges represent challenges utilizing delivered files and information.\nShared challenges represent challenges that are hosted on a server and shared among multiple teams.\nInstanced challenges represent challenges that are hosted on a server and are unique to each user/team.', - ) - instanced_type: Optional[InstancedType] = Field( - 'none', - description='The type of instanced challenge.\nDefines how users interact with the challenge.', - ) - instanced_name: Optional[ - constr(regex=r'^[a-z0-9-]+$', min_length=1, max_length=50) - ] = None - instanced_subdomains: Optional[List[InstancedSubdomain]] = Field( - [], - description='The subdomains of the instanced challenge. This is applicable if the `type` is `instanced` and the challenge is hosted on a server. It will be prefixed with the instanced domain.', - examples=[['web', 'admin', 'api'], ['api']], - max_items=5, - min_items=0, - unique_items=True, - ) - connection: Optional[constr(max_length=255)] = Field( - None, - description='The connection string for the challenge. This is applicable if the `type` is not `instanced` and the challenge is hosted on a server. It is used to display connection information for the challenge. You can use the variable `${host}` in the string, which will be replaced with the website domain when rendered.', - examples=['http://example.com', 'nc example.com 1337', 'nc ${host} 1337'], - ) - points: Optional[conint(ge=1, le=10000)] = Field( - 1000, description='The amount of points the challenge is worth from the start' - ) - decay: Optional[conint(ge=0, le=10000)] = Field( - 75, description='The percentage of points lost over time' - ) - min_points: Optional[conint(ge=1, le=1000)] = Field( - 100, - description='The minimum amount of points the challenge can be worth. min_points must be less than or equal to points.', - ) - flag: Union[ - constr( - regex=r'^(\w{2,10}\{[^}]*\}|dynamic|null)$', min_length=4, max_length=1000 - ), - List[ - Union[ - constr( - regex=r'^(\w{2,10}\{[^}]*\}|dynamic|null)$', - min_length=4, - max_length=1000, - ), - Flag, - ] - ], - ] = Field( - ..., - description="The flag of the challenge. May be a single string or an array of strings. Each may be 'dynamic', 'null', or a flag like 'flag{...}'. `flag` may be replaced with between 2 and 10 word chars. If case sensitivity is not explicitly specified, the flag is case sensitive.", - examples=[ - 'flag{flag}', - ['flag{flag1}', 'flag{flag2}'], - [ - {'flag': 'flag{flag1}', 'case_sensitive': True}, - {'flag': 'flag{flag2}', 'case_sensitive': False}, - ], - ], - ) - description_location: Optional[constr(regex=r'^[a-zA-Z0-9-_/.]+\.md$')] = Field( - 'description.md', - description='The location of the description file relative to the challenge directory', - examples=['description.md'], - ) - dockerfile_locations: Optional[List[DockerfileLocation]] = Field( - [], - description='The locations of the Dockerfiles relative to the challenge directory.\nMultiple Dockerfiles can be specified if the challenge requires multiple images.', - ) - handout_dir: Optional[constr(regex=r'^[a-zA-Z0-9-_/]+$')] = Field( - 'handout', - description='The location of the handout directory relative to the challenge directory. Handout directory contains all files that are handed out to users (zipped to a single file that are stored in _.zip in the files directory).', - examples=['handout'], - ) diff --git a/src/library/config.py b/src/library/config.py index f12023c..d8c59bc 100644 --- a/src/library/config.py +++ b/src/library/config.py @@ -1,3 +1,11 @@ +from pathlib import Path + +# Default to the directory where the command is run from +CHALLENGE_REPO_ROOT = Path.cwd() + +CHALLENGE_SCHEMA = "https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json" +PAGE_SCHEMA = "https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json" + CHALL_TYPES = [ "static", "shared", "instanced" ] DIFFICULTIES = [ "beginner", "easy", "easy-medium", "medium", "medium-hard","hard", "very-hard", "insane"] CATEGORIES = [ "web", "forensics", "rev", "crypto", "pwn", "boot2root", "osint", "misc", "blockchain", "mobile", "test" ] diff --git a/src/library/generator.py b/src/library/generator.py index feade57..0d6a91c 100644 --- a/src/library/generator.py +++ b/src/library/generator.py @@ -6,6 +6,7 @@ from .data import Challenge, Page from .utils import Utils +from .config import CHALLENGE_SCHEMA class Generator: challenge: Challenge @@ -180,9 +181,9 @@ def challenge_file(self, format: Literal["yml", "yaml", "json"] = "yml"): # Create challenge file path = Path(self.path) path = path.joinpath(f"challenge.{format}") - content = self.challenge.str_yml("./../../../tools/schema/challenge-schema.json") + content = self.challenge.str_yml(CHALLENGE_SCHEMA) if format == "json": - content = self.challenge.str_json("./../../../tools/schema/challenge-schema.json") + content = self.challenge.str_json(CHALLENGE_SCHEMA) with open(path, "w") as f: f.write(content) diff --git a/src/library/utils.py b/src/library/utils.py index eaf3230..3838e27 100644 --- a/src/library/utils.py +++ b/src/library/utils.py @@ -3,25 +3,27 @@ import yaml import json +from .config import CHALLENGE_REPO_ROOT + class Utils: @staticmethod - def get_repo_dir(): - return Path(__file__).resolve().parents[2] + def get_repo_dir() -> Path: + return CHALLENGE_REPO_ROOT @staticmethod - def get_challenges_dir(): + def get_challenges_dir() -> Path: return Utils.get_repo_dir().joinpath('challenges') @staticmethod - def get_pages_dir(): + def get_pages_dir() -> Path: return Utils.get_repo_dir().joinpath('pages') @staticmethod - def get_challenge_dir(category: str, slug: str): + def get_challenge_dir(category: str, slug: str) -> Path: return Utils.get_challenges_dir().joinpath(Utils.slugify(category) or "").joinpath(slug) @staticmethod - def get_page_dir(page: str): + def get_page_dir(page: str) -> Path: return Utils.get_pages_dir().joinpath(Utils.slugify(page) or "") @staticmethod @@ -37,19 +39,19 @@ def get_k8s_dir(category: str, slug: str): return Utils.get_challenge_dir(category, slug).joinpath('k8s') @staticmethod - def get_k8s_page_dir(page: str): + def get_k8s_page_dir(page: str) -> Path: return Utils.get_page_dir(page).joinpath('k8s') @staticmethod - def get_challenge_render_dir(category: str, slug: str): + def get_challenge_render_dir(category: str, slug: str) -> Path: return Utils.get_k8s_dir(category, slug).joinpath('challenge') @staticmethod - def get_configmap_dir(category: str, slug: str): + def get_configmap_dir(category: str, slug: str) -> Path: return Utils.get_k8s_dir(category, slug).joinpath('config') @staticmethod - def get_template_dir(): + def get_template_dir() -> Path: return Utils.get_repo_dir().joinpath('template') @staticmethod diff --git a/src/models/__init__.py b/src/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/challenge.py b/src/models/challenge.py deleted file mode 100644 index 6d7b0ce..0000000 --- a/src/models/challenge.py +++ /dev/null @@ -1,307 +0,0 @@ -# generated by datamodel-codegen: -# filename: https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json -# timestamp: 2025-11-26T22:40:56+00:00 - -from __future__ import annotations - -from enum import Enum -from typing import Optional, Sequence, Union - -from pydantic import BaseModel, Field, RootModel -from typing_extensions import Annotated - - -class Tag(RootModel[str]): - root: Annotated[ - str, Field(max_length=50, min_length=1, pattern='^[a-zA-Z0-9-_:;? ]+$') - ] - - -class Prerequisite(RootModel[str]): - root: Annotated[str, Field(max_length=100, min_length=1, pattern='^[a-z0-9-]+$')] - """ - The prerequisites of the challenge. Must be the slugs of the challenges. - """ - - -class Category(Enum): - web = 'web' - forensics = 'forensics' - rev = 'rev' - crypto = 'crypto' - pwn = 'pwn' - boot2root = 'boot2root' - osint = 'osint' - misc = 'misc' - blockchain = 'blockchain' - mobile = 'mobile' - test = 'test' - - -class Difficulty(Enum): - beginner = 'beginner' - easy = 'easy' - easy_medium = 'easy-medium' - medium = 'medium' - medium_hard = 'medium-hard' - hard = 'hard' - very_hard = 'very-hard' - insane = 'insane' - - -class Type(Enum): - static = 'static' - shared = 'shared' - instanced = 'instanced' - - -class InstancedType(Enum): - web = 'web' - tcp = 'tcp' - none = 'none' - - -class InstancedName(RootModel[str]): - root: Annotated[ - str, - Field( - examples=['example-challenge'], - max_length=50, - min_length=1, - pattern='^[a-z0-9-]+$', - ), - ] - """ - The slug of the instanced challenge. This is applicable if the `type` is `instanced` and another instance is used instead of itself. May also be itself to specify that the challenge is instanced but does not use another instance. - """ - - -class InstancedSubdomain(RootModel[str]): - root: Annotated[ - str, - Field( - examples=['web', 'admin', 'api', 'tcp:service', 'web:dashboard'], - max_length=10, - min_length=0, - pattern='^((web|tcp):)?[a-z0-9-]+$', - ), - ] - """ - The subdomain of the instanced challenge. This is applicable if the `type` is `instanced` and the challenge is hosted on a server. It will be prefixed with the instanced domain. Each entry may optionally be prefixed with `web:` or `tcp:` (e.g., `web:api`, `tcp:service`, or just `admin`). The prefix indicates the protocol associated with the subdomain. - """ - - -class Flag(RootModel[str]): - root: Annotated[ - str, - Field( - examples=[ - 'flag{flag}', - ['flag{flag1}', 'flag{flag2}'], - [ - {'flag': 'flag{flag1}', 'case_sensitive': True}, - {'flag': 'flag{flag2}', 'case_sensitive': False}, - ], - ], - max_length=1000, - min_length=4, - pattern='^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$', - ), - ] - """ - The flag of the challenge. May be a single string or an array of strings. Each may be 'dynamic', 'null', or a flag like 'flag{...}'. `flag` may be replaced with between 2 and 10 word chars. If case sensitivity is not explicitly specified, the flag is case sensitive. - """ - - -class Flag11(RootModel[str]): - root: Annotated[ - str, - Field( - max_length=1000, - min_length=4, - pattern='^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$', - ), - ] - - -class Flag12(BaseModel): - flag: Annotated[ - str, - Field( - max_length=1000, - min_length=4, - pattern='^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$', - ), - ] - case_sensitive: Annotated[Optional[bool], Field(examples=[True, False])] = True - """ - Whether the flag is case sensitive or not. If false, the flag will be checked case insensitively. Default is true. - """ - - -class Flag1(RootModel[Sequence[Union[Flag11, Flag12]]]): - root: Annotated[ - Sequence[Union[Flag11, Flag12]], - Field( - examples=[ - 'flag{flag}', - ['flag{flag1}', 'flag{flag2}'], - [ - {'flag': 'flag{flag1}', 'case_sensitive': True}, - {'flag': 'flag{flag2}', 'case_sensitive': False}, - ], - ], - min_length=1, - ), - ] - """ - The flag of the challenge. May be a single string or an array of strings. Each may be 'dynamic', 'null', or a flag like 'flag{...}'. `flag` may be replaced with between 2 and 10 word chars. If case sensitivity is not explicitly specified, the flag is case sensitive. - """ - - -class DockerfileLocation(BaseModel): - location: Annotated[ - str, Field(examples=['src/Dockerfile'], pattern='^[a-zA-Z0-9-_/.]+$') - ] - """ - The location of the Dockerfile relative to the challenge directory - """ - context: Annotated[str, Field(examples=['src/'], pattern='^[a-zA-Z0-9-_/.]+$')] - """ - The context of the Dockerfile relative to the challenge directory - """ - identifier: Annotated[Optional[str], Field(examples=['None', None])] = None - """ - The identifier of the Dockerfile to suffix the docker image with (None, null, or empty string for no suffix) - """ - - -class Model(BaseModel): - version: Annotated[ - Optional[str], - Field( - examples=['1', '1.0', '1.0.0'], pattern='^(\\d+\\.)?(\\d+\\.)?(\\*|\\d+)$' - ), - ] = '1' - """ - The version of the challenge schema - """ - enabled: bool - """ - Whether the challenge is enabled or not. If disabled, this forces the challenge to not be deployed or interacted with. - Should only be false if the challenge is not ready. - """ - name: Annotated[ - str, Field(examples=['Example Challenge'], max_length=50, min_length=1) - ] - """ - The name of the challenge - """ - slug: Annotated[ - str, - Field( - examples=['example-challenge'], - max_length=50, - min_length=1, - pattern='^[a-z0-9-]+$', - ), - ] - """ - The slug of the challenge - """ - author: Annotated[str, Field(examples=['John Smith'], max_length=100, min_length=1)] - """ - The author of the challenge - """ - tags: Optional[Sequence[Tag]] = [] - """ - Tags for the challenge. Used for filtering and searching. - """ - prerequisites: Optional[Sequence[Prerequisite]] = None - """ - Required challenges to solve before this challenge can be displayed - """ - category: Category - """ - The category of the challenge - """ - difficulty: Difficulty - """ - The difficulty of the challenge - """ - type: Type - """ - The type of challenge. - Static challenges represent challenges utilizing delivered files and information. - Shared challenges represent challenges that are hosted on a server and shared among multiple teams. - Instanced challenges represent challenges that are hosted on a server and are unique to each user/team. - """ - instanced_type: Optional[InstancedType] = InstancedType.none - """ - The type of instanced challenge. - Defines how users interact with the challenge. - """ - instanced_name: Optional[InstancedName] = None - instanced_subdomains: Annotated[ - Optional[Sequence[InstancedSubdomain]], - Field(examples=[['web', 'admin', 'api'], ['api']], max_length=5, min_length=0), - ] = [] - """ - The subdomains of the instanced challenge. This is applicable if the `type` is `instanced` and the challenge is hosted on a server. It will be prefixed with the instanced domain. - """ - connection: Annotated[ - Optional[str], - Field( - examples=['http://example.com', 'nc example.com 1337', 'nc ${host} 1337'], - max_length=255, - ), - ] = None - """ - The connection string for the challenge. This is applicable if the `type` is not `instanced` and the challenge is hosted on a server. It is used to display connection information for the challenge. You can use the variable `${host}` in the string, which will be replaced with the website domain when rendered. - """ - points: Annotated[Optional[int], Field(ge=1, le=10000)] = 1000 - """ - The amount of points the challenge is worth from the start - """ - decay: Annotated[Optional[int], Field(ge=0, le=10000)] = 75 - """ - The percentage of points lost over time - """ - min_points: Annotated[Optional[int], Field(ge=1, le=1000)] = 100 - """ - The minimum amount of points the challenge can be worth. min_points must be less than or equal to points. - """ - flag: Annotated[ - Union[Flag, Flag1], - Field( - examples=[ - 'flag{flag}', - ['flag{flag1}', 'flag{flag2}'], - [ - {'flag': 'flag{flag1}', 'case_sensitive': True}, - {'flag': 'flag{flag2}', 'case_sensitive': False}, - ], - ] - ), - ] - """ - The flag of the challenge. May be a single string or an array of strings. Each may be 'dynamic', 'null', or a flag like 'flag{...}'. `flag` may be replaced with between 2 and 10 word chars. If case sensitivity is not explicitly specified, the flag is case sensitive. - """ - description_location: Annotated[ - Optional[str], - Field(examples=['description.md'], pattern='^[a-zA-Z0-9-_/.]+\\.md$'), - ] = 'description.md' - """ - The location of the description file relative to the challenge directory - """ - dockerfile_locations: Optional[Sequence[DockerfileLocation]] = [] - """ - The locations of the Dockerfiles relative to the challenge directory. - Multiple Dockerfiles can be specified if the challenge requires multiple images. - """ - handout_dir: Annotated[ - Optional[str], Field(examples=['handout'], pattern='^[a-zA-Z0-9-_/]+$') - ] = 'handout' - """ - The location of the handout directory relative to the challenge directory. Handout directory contains all files that are handed out to users (zipped to a single file that are stored in _.zip in the files directory). - """ diff --git a/src/models/page.py b/src/models/page.py deleted file mode 100644 index 8f1025c..0000000 --- a/src/models/page.py +++ /dev/null @@ -1,80 +0,0 @@ -# generated by datamodel-codegen: -# filename: https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json -# timestamp: 2025-11-26T22:41:04+00:00 - -from __future__ import annotations - -from enum import Enum -from typing import Optional - -from pydantic import BaseModel, Field -from typing_extensions import Annotated - - -class Format(Enum): - markdown = 'markdown' - html = 'html' - - -class Model(BaseModel): - version: Annotated[ - Optional[str], - Field( - examples=['1', '1.0', '1.0.0'], pattern='^(\\d+\\.)?(\\d+\\.)?(\\*|\\d+)$' - ), - ] = '1' - """ - The version of the page schema - """ - enabled: bool - """ - Whether the page is enabled or not. If disabled, this forces the page to not be deployed or interacted with. - """ - slug: Annotated[ - str, - Field( - examples=['example-page'], - max_length=50, - min_length=1, - pattern='^[a-z0-9-]+$', - ), - ] - """ - The slug of the page - """ - title: Annotated[ - str, Field(examples=['Example Page'], max_length=100, min_length=1) - ] - """ - The title of the page - """ - route: Annotated[ - str, Field(examples=['/example-page'], max_length=100, min_length=1) - ] - """ - The route of the page, used for navigation - """ - content: Annotated[ - str, - Field( - examples=['page.md', 'README.md', 'example-page.md'], - max_length=255, - min_length=1, - pattern='^([a-zA-Z0-9-_./]+\\.(md|html))$', - ), - ] - """ - The path to the content file for the page. This file should be located in the repository and contain the content of the page in Markdown or HTML format. - """ - auth: Optional[bool] = False - """ - Whether the page requires authentication to view. If true, only authenticated users can access the page. - """ - draft: Optional[bool] = False - """ - Whether the page is a draft or not. If true, this indicates that the page is still under development and not ready for public viewing. - """ - format: Format - """ - The format of the page content. Can be either 'markdown' or 'html'. This determines how the content is rendered on the page. - """ From fd5617846e9309b6309f28f172a56e4167121cd2 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Mon, 1 Dec 2025 23:24:57 +0100 Subject: [PATCH 03/26] Refactor README for clarity and add environment variable support; update slugify argument to positional --- README.md | 389 ++++++++++++++++++++++++++++++++++++++-- src/commands/slugify.py | 2 +- src/library/config.py | 12 +- 3 files changed, 380 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index acf4e6c..3cf09f5 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,17 @@ pip install -r challenge-toolkit/src/requirements.txt You can then run the tool using python: ```sh -python challenge-toolkit/src/ctf.py [options] +python challenge-toolkit/src/ctf.py [arguments] [options] ``` +### Environment Variables + +The toolkit supports the following optional environment variables: + +| Variable | Description | Used By | +| ------------------- | ---------------------------------------------------------------------- | ------------------ | +| `GITHUB_REPOSITORY` | GitHub repository in format `owner/repo` (e.g., `ctfpilot/challenges`) | `template`, `page` | + ### Dependencies Currently, the following dependencies are required: @@ -38,10 +46,11 @@ Currently, the following dependencies are required: - Python 3.8 or higher - `pyyaml` Python package - `python-slugify` Python package +- Docker (for building challenge images with the `pipeline` command) `pyyaml` and `python-slugify` are defined in the `requirements.txt` file. -### Including it in your project as a git submodule +### Including the tool in your project as a git submodule One way to include it into your own project is to add it as a git submodule: @@ -61,14 +70,314 @@ Or if you already have cloned your repository, run: git submodule update --init --recursive ``` +### Typical usage + +The tool is typically split up into three main parts: + +1. **Creating a new challenge** using the `create` command. + The `slugify` command may be used to create the slug for the challenge, based on the name. +2. **Building resources for a challenge**. This includes: + 1. **Building Docker images** using the `pipeline` command. + 2. **Rendering Kubernetes deployment files** using the `template` command, for each type of render, in the order of `clean`, `k8s`, `configmap`, `handout`. +3. **Rendering CTFd pages** using the `page` command. + +### Configuration + +The toolkit can be configured, by configuring the `src/library/config.py` file. +This is important, if you have a custom challenge schema or page schema. + +Default values: + +```py +# Path to the root of the challenge repository +CHALLENGE_REPO_ROOT = Path.cwd() # Default to the directory where the command is run from + +# Challenge and Page schema URLs +CHALLENGE_SCHEMA = "https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json" +PAGE_SCHEMA = "https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json" + +# Allowed values for schema fields +CHALL_TYPES = [ "static", "shared", "instanced" ] +DIFFICULTIES = [ "beginner", "easy", "easy-medium", "medium", "medium-hard","hard", "very-hard", "insane"] +CATEGORIES = [ "web", "forensics", "rev", "crypto", "pwn", "boot2root", "osint", "misc", "blockchain", "mobile", "test" ] +INSTANCED_TYPES = [ "none", "web", "tcp" ] # "none" is the default. Defines how users interact with the challenge. + +# Regex patterns for tag and flag validation +TAG_FORMAT = "^[a-zA-Z0-9-_:;? ]+$" +FLAG_FORMAT = "^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$" + +# Default challenge configuration values +DEFAULT = { + "enabled": False, + "name": None, + "slug": None, + "author": None, + "category": None, + "difficulty": None, + "type": None, + "tags": [], + "instanced_name": None, + "instanced_type": "none", + "instanced_subdomains": [], + "connection": None, + "flag": {"flag": "null", "case_sensitive": False}, + "points": 1000, + "decay": 75, + "min_points": 100, + "description_location": "description.md", + "handout_dir": "handout" +} +``` + ## Commands -The tool provides the following commands: +The toolkit provides several commands to manage CTF challenges throughout their lifecycle. All commands follow the format: + +```sh +python challenge-toolkit/src/ctf.py [arguments] [options] +``` + +### Command Overview + +| Command | Purpose | Key Arguments | +| ---------- | ------------------------------------------- | -------------------------------------------- | +| `create` | Bootstrap a new challenge | Options for name, category, difficulty, etc. | +| `template` | Generate K8s files, ConfigMaps, or handouts | `` `` | +| `pipeline` | Build and tag Docker images | `` `` `` | +| `page` | Generate ConfigMaps for CTFd pages | `` | +| `slugify` | Convert strings to URL-safe slugs | `` | + +### `create` - Create a new challenge + +Bootstrap a new challenge with the proper directory structure and template files. + +**Usage:** + +> [!IMPORTANT] +> The challenge will be created in the current working directory, following the challenge repository structure defined in the [Challenge repository structure](#challenge-repository-structure) section. +> The new challenge will then be located in `challenges///`. + +```sh +python challenge-toolkit/src/ctf.py create [options] +``` + +**Options:** + +| Option | Description | Default | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| `--no-prompts` | Skip interactive prompts and use default/provided values | Interactive mode | +| `--name ` | Name of the challenge | Prompted | +| `--slug ` | URL-safe identifier for the challenge | Prompted | +| `--author ` | Challenge author name | Prompted | +| `--category ` | Challenge category | Prompted | +| `--difficulty ` | Challenge difficulty | Prompted | +| `--type ` | Challenge type: `static`, `shared`, or `instanced` | Prompted | +| `--instanced-type ` | For instanced challenges: `none`, `web`, or `tcp`. When `web` or `tcp` is provided for a non-static challenge, deployment templates will be generated. | `none` | +| `--flag ` | Challenge flag (format: `FLAG{...}` or `dynamic` or `null`) | Prompted | +| `--points ` | Initial points for the challenge | `1000` | +| `--min-points ` | Minimum points (for dynamic scoring) | `100` | +| `--description-location ` | Path to the challenge description file | `description.md` | +| `--dockerfile-location ` | Path to the Dockerfile (relative to challenge directory) | `src/Dockerfile` | +| `--dockerfile-context ` | Docker build context path | `src/` | +| `--dockerfile-identifier ` | Identifier for multiple Dockerfiles | `None` | +| `--handout_location ` | Directory containing files to hand out to participants | `handout` | + +**Examples:** + +```sh +# Interactive mode (recommended for first-time users) +python challenge-toolkit/src/ctf.py create + +# Non-interactive mode with all parameters +python challenge-toolkit/src/ctf.py create \ + --no-prompts \ + --name "SQL Injection 101" \ + --slug "sql-injection-101" \ + --author "John Doe" \ + --category web \ + --difficulty easy \ + --type instanced \ + --instanced-type web \ + --flag "FLAG{sql_1nj3ct10n_1s_fun}" \ + --points 500 \ + --min-points 100 +``` + +### `template` - Render Kubernetes templates + +Generate Kubernetes deployment files, ConfigMaps, or handout archives for challenges. + +**Usage:** + +> [!IMPORTANT] +> The command should be run from the root of a challenge repository, as it relies on the challenge directory structure defined in the [Challenge repository structure](#challenge-repository-structure) section. + +```sh +python challenge-toolkit/src/ctf.py template [options] +``` + +**Arguments:** + +| Argument | Description | Required | +| ------------- | ------------------------------------------------------------------------ | -------- | +| `` | Type of rendering: `k8s`, `configmap`, `clean`, or `handout` | Yes | +| `` | Challenge path in format `category/slug` (e.g., `web/sql-injection-101`) | Yes | + +**Options:** + +| Option | Description | Default | +| ----------------------- | ------------------------------------------------- | -------------------------------------------- | +| `--expires ` | Time in seconds until challenge instance expires | `3600` (1 hour) | +| `--available ` | Time in seconds until challenge becomes available | `0` (immediately) | +| `--repo ` | GitHub repository in format `owner/repo` | `$GITHUB_REPOSITORY` env or empty (see note) | + +> [!NOTE] +> The `--repo` option defaults to the `GITHUB_REPOSITORY` environment variable. If neither is set, the command will fail. This is typically set automatically in GitHub Actions workflows. + +**Renderer Types:** + +- **`k8s`** - Generate Kubernetes deployment YAML files for the challenge +- **`configmap`** - Generate ConfigMap containing challenge metadata and description +- **`clean`** - Remove all generated Kubernetes files from the `k8s/` directory +- **`handout`** - Create a ZIP archive of files in the handout directory + +**Examples:** + +```sh +# Generate Kubernetes deployment files +python challenge-toolkit/src/ctf.py template k8s web/sql-injection-101 + +# Generate ConfigMap with custom expiry time (2 hours) and repo +python challenge-toolkit/src/ctf.py template configmap web/sql-injection-101 \ + --expires 7200 \ + --repo ctfpilot/ctfpilot/ctf-challenges + +# Create handout archive +python challenge-toolkit/src/ctf.py template handout web/sql-injection-101 + +# Clean generated files +python challenge-toolkit/src/ctf.py template clean web/sql-injection-101 +``` + +### `pipeline` - Build and tag Docker images + +Build Docker images for challenges and tag them appropriately for container registry deployment. + +**Usage:** + +> [!IMPORTANT] +> The command should be run from the root of a challenge repository, as it relies on the challenge directory structure defined in the [Challenge repository structure](#challenge-repository-structure) section. + +```sh +python challenge-toolkit/src/ctf.py pipeline [options] +``` + +**Arguments:** + +| Argument | Description | Required | +| ---------------- | ----------------------------------------------------------------- | -------- | +| `` | Challenge path in format `category/slug` (e.g., `web/example`) | Yes | +| `` | Container registry URL (e.g., `ghcr.io`, `docker.io`) | Yes | +| `` | Prefix for Docker image names, such as the name of the repository | Yes | + +**Options:** + +| Option | Description | Default | +| ------------------------- | ------------------------------- | ------- | +| `--image_suffix ` | Suffix to append to image names | None | -| Command | Description | -|-----------|--------------------------------------------------| -| `create` | Create a new challenge based on the template. | -| `pipeline`| Run the pipeline actions on all challenges. | +**Behavior:** + +- Automatically increments the challenge version +- Builds Docker images using the Dockerfile locations specified in `challenge.yml` +- Tags images with both `:latest` and `:version` tags +- Image naming: `/--[-identifier][-suffix]` + +**Examples:** + +```sh +# Build and tag Docker image +python challenge-toolkit/src/ctf.py pipeline \ + web/sql-injection-101 \ + ghcr.io \ + ctfpilot/ctf-challenges + +# Build with custom suffix (e.g., for staging) +python challenge-toolkit/src/ctf.py pipeline \ + web/sql-injection-101 \ + ghcr.io \ + ctfpilot/ctf-challenges \ + --image_suffix staging + +# Result: ghcr.io/ctfpilot/ctf-challenges-web-sql-injection-101:latest +# ghcr.io/ctfpilot/ctf-challenges-web-sql-injection-101:1 +``` + +### `page` - Render CTFd pages + +Generate Kubernetes ConfigMaps pages, following the [CTF Pilot's Page Schema](https://github.com/ctfpilot/page-schema). + +**Usage:** + +> [!IMPORTANT] +> The command should be run from the root of a challenge repository, as it relies on the challenge directory structure defined in the [Challenge repository structure](#challenge-repository-structure) section. + +```sh +python challenge-toolkit/src/ctf.py page [options] +``` + +**Arguments:** + +| Argument | Description | Required | +| -------- | ---------------------------------- | -------- | +| `` | Page path (e.g., `rules`, `about`) | Yes | + +**Options:** + +| Option | Description | Default | +| --------------------- | ---------------------------------------- | -------------------------------------------- | +| `--repo ` | GitHub repository in format `owner/repo` | `$GITHUB_REPOSITORY` env or empty (see note) | + +> [!NOTE] +> The `--repo` option defaults to the `GITHUB_REPOSITORY` environment variable. If neither is set, the command will fail. This is typically set automatically in GitHub Actions workflows. + +**Examples:** + +```sh +# Render a custom page +python challenge-toolkit/src/ctf.py page rules --repo ctfpilot/ctfpilot/ctf-challenges + +# Render about page +python challenge-toolkit/src/ctf.py page about +``` + +### `slugify` - Convert strings to URL-safe slugs + +Utility command to convert challenge names into URL-safe slugs following the toolkit's conventions. + +**Usage:** + +```sh +python challenge-toolkit/src/ctf.py slugify +``` + +**Arguments:** + +| Argument | Description | Required | +| -------- | ------------------------- | -------- | +| `` | String to convert to slug | Yes | + +**Examples:** + +```sh +# Convert challenge name to slug +python challenge-toolkit/src/ctf.py slugify "SQL Injection 101" +# Output: sql-injection-101 + +# Convert with special characters +python challenge-toolkit/src/ctf.py slugify "Web: XSS & CSRF" +# Output: web-xss-csrf +``` ## Challenge repository structure @@ -97,11 +406,14 @@ The structure is as follows: │ ├── blockchain │ └── beginner/ │ └── challenge-1 +├── pages/ +│ └── page-1/ ├── template/ ├── challenge-toolkit/ └── ``` +*Pages may be split into their own repository, if desired.* ### Challenge structure @@ -140,7 +452,7 @@ The subdirectory structure of a challenge is as follows: To learn more about the `challenge.yml` file, see the [CTF Pilot's Challenge Schema](https://github.com/ctfpilot/challenge-schema). -### Challenges with Dockerfiles +#### Challenges with Dockerfiles It is very common to use Docker for challenges, as it is the core for shared and instanced challenges. @@ -191,26 +503,65 @@ The folder structure for this example would be:
-The `pipeline` command tags docker images with the following format: +The Docker image naming convention is described in the [`pipeline` command section](#pipeline---build-and-tag-docker-images) above. -```txt -ghcr.io/---: -``` +### Template structure -Examples of docker image tags: +> [!TIP] +> The template directory contains global templates for the challenge deployment. +> +> Default templates are provided in the `challenge-toolkit/template/` directory, however they must be moved to the repository `template/` directory in order to be used. -With identifier: +The `template/` directory contains the base templates for the challenge deployment files. +These templates are used to generate the actual deployment files in the `k8s/` directory, when running the `template` and `page` command. +They are also used in the initial challenge creation, when running the `create` command. -```txt -ghcr.io/ctfpilot/example-web-challenge-1-web:latest -``` +The following templates are required: -Without identifier: +- ConfigMap templates: + - `challenge-configmap.yml` + - `page-configmap.yml` +- Challenge deployment templates: + - Web: `instanced-web-k8s.yml` + - TCP: `instanced-tcp-k8s.yml` +- [kube-ctf](https://github.com/ctfpilot/kube-ctf) deployment template: + - `instanced-k8s-challenge.yml` + +Configmap tempaltes are used to generate ConfigMaps for challenges and pages. +Challenge deployment templates are used to generate the Kubernetes deployment files for challenges. +The `kube-ctf` deployment template is used to generate the deployment file for instanced challenges, when using the [kube-ctf](https://github.com/ctfpilot/kube-ctf) platform. Within this template, the challenge deployment tempalte is embedded. + +> [!NOTE] +> Only instanced templates are currently generated. Shared templates are not yet supported. + +### Page structure + +> [!NOTE] +> Pages may be stored in their own repository, if desired. + +> [!TIP] +> Page content is located in the root of the page directory (e.g., `page.html` or `page.md`). +> The main files are `page.yml` (or `page.json`) and the content file. + +Each page is stored in its own directory under the `pages/` directory in the repository root. +Pages are used to create custom pages in CTFd, such as rules, about pages, or other informational content. + +The subdirectory structure of a page is as follows: ```txt -ghcr.io/ctfpilot/example-web-challenge-1:latest +. +├── k8s/ +├── page.html (or page.md, page.txt) +├── page.yml (or page.json) +└── version ``` +- `k8s/` contains the Kubernetes ConfigMap file for the page. This is automatically generated by the `page` command and should not be modified manually. +- `page.html` (or `page.md`, `page.txt`) contains the actual content of the page. The filename is specified in `page.yml` via the `content` field. The content can be in HTML or Markdown format. +- `page.yml` contains the metadata for the page. This must be filled out by the page creator. Follows a strict structure defined by the [CTF Pilot's Page Schema](https://github.com/ctfpilot/page-schema). + The file may be replaced by a JSON file, as `page.json`. +- `version` contains the version of the page. This is automatically updated by the `page` command and contains a single number representing the version. + ## Contributing We welcome contributions of all kinds, from **code** and **documentation** to **bug reports** and **feedback**! diff --git a/src/commands/slugify.py b/src/commands/slugify.py index 86e6f7e..0c834d5 100644 --- a/src/commands/slugify.py +++ b/src/commands/slugify.py @@ -18,7 +18,7 @@ def __init__(self, parent_parser = None): else: self.parser = argparse.ArgumentParser(description="Slugify a string for use in challenge slug") - self.parser.add_argument("--name", help="Name to slugify", required=True) + self.parser.add_argument("name", help="Name to slugify", required=True) def parse(self): if self.subcommand: diff --git a/src/library/config.py b/src/library/config.py index d8c59bc..bd72fae 100644 --- a/src/library/config.py +++ b/src/library/config.py @@ -1,17 +1,23 @@ from pathlib import Path -# Default to the directory where the command is run from -CHALLENGE_REPO_ROOT = Path.cwd() +# Path to the root of the challenge repository +CHALLENGE_REPO_ROOT = Path.cwd() # Default to the directory where the command is run from +# Challenge and Page schema URLs CHALLENGE_SCHEMA = "https://raw.githubusercontent.com/ctfpilot/challenge-schema/refs/heads/main/schema.json" PAGE_SCHEMA = "https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads/main/schema.json" +# Allowed values for schema fields CHALL_TYPES = [ "static", "shared", "instanced" ] DIFFICULTIES = [ "beginner", "easy", "easy-medium", "medium", "medium-hard","hard", "very-hard", "insane"] CATEGORIES = [ "web", "forensics", "rev", "crypto", "pwn", "boot2root", "osint", "misc", "blockchain", "mobile", "test" ] +INSTANCED_TYPES = [ "none", "web", "tcp" ] # "none" is the default. Defines how users interact with the challenge. + +# Regex patterns for tag and flag validation TAG_FORMAT = "^[a-zA-Z0-9-_:;? ]+$" FLAG_FORMAT = "^(\\w{2,10}\\{[^}]*\\}|dynamic|null)$" -INSTANCED_TYPES = [ "none", "web", "tcp" ] # "none" is the default. Defines how users interact with the challenge. + +# Default challenge configuration values DEFAULT = { "enabled": False, "name": None, From 9b1f5af83605fdba9eb5d6608fd262871d084bd0 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Tue, 2 Dec 2025 22:21:59 +0100 Subject: [PATCH 04/26] Enhance README for clarity and detail; improve descriptions of toolkit functionality and usage scenarios --- README.md | 68 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3cf09f5..89a0a3f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # CTF Pilot's Challenge Toolkit -Challenge Toolkit for CTF Pilot. -Allows for bootstrapping challenges and pipeline actions on challenges. +A comprehensive CLI toolkit for CTF challenge development, deployment, and management. + +The Challenge Toolkit streamlines the entire CTF challenge lifecycle, from bootstrapping new challenges with proper directory structures to building Docker images and generating Kubernetes deployment manifests. Built to work seamlessly with [CTF Pilot's infrastructure](https://github.com/ctfpilot), it enforces standardized schemas and automates repetitive tasks, letting you focus on creating great challenges instead of managing boilerplate. ## How to run @@ -31,6 +32,14 @@ You can then run the tool using python: python challenge-toolkit/src/ctf.py [arguments] [options] ``` +In order to use `create`, `template`, and `page` you need to copy the deployment templates into the `template/` directory of your challenge repository (In acordance with the **[Template structure](#template-structure)** section). + +This can be done by running: + +```sh +cp -r challenge-toolkit/template/ . +``` + ### Environment Variables The toolkit supports the following optional environment variables: @@ -72,7 +81,7 @@ git submodule update --init --recursive ### Typical usage -The tool is typically split up into three main parts: +The tool is typically used in three scenarios: 1. **Creating a new challenge** using the `create` command. The `slugify` command may be used to create the slug for the challenge, based on the name. @@ -236,10 +245,41 @@ python challenge-toolkit/src/ctf.py template [options] **Renderer Types:** -- **`k8s`** - Generate Kubernetes deployment YAML files for the challenge -- **`configmap`** - Generate ConfigMap containing challenge metadata and description +- **`k8s`** - Generate Kubernetes deployment YAML files for the challenge. + + If the challenge is of type `instanced`, it will template from the `template/k8s.yml` file, into the `k8s/challenge/k8s.yml` file. It will wrap the challenge template into the `kube-ctf` deployment template. + + If the challenge is of type `shared` or `static`, it will template from the `template/k8s.yml` file, into the `k8s/challenge/template/k8s.yml` file, along with a full helm chart located in `k8s/challenge/`. + + It will template the following fields: + - `CHALLENGE_NAME` - Challenge slug + - `CHALLENGE_CATEGORY` - Challenge category + - `CHALLENGE_TYPE` - Challenge type + - `CHALLENGE_VERSION` - Challenge version + - `CHALLENGE_EXPIRES` - Expiry time in seconds + - `CHALLENGE_AVAILABLE_AT` - When the challenge becomes available + - `DOCKER_IMAGE` - Category and slug combined to docker image. Will not follow the format produced by the `pipeline` command. + + Templating is done using `{{ VARIABLE_NAME }}` syntax. +- **`configmap`** - Generate helm chart containing challenge metadata and description, which produces a ConfigMap for the [CTF Pilot's CTFd Manager](https://github.com/ctfpilot/ctfd-manager). + + This will render the `challenge-configmap.yml` from the global template directory, into the `k8s/config/templates/k8s.yml` file, along with a full helm chart located in `k8s/config/`. + + It will template the following fields: + - `CHALLENGE_NAME` - Challenge slug + - `CHALLENGE_CATEGORY` - Challenge category + - `CHALLENGE_REPO` - GitHub repository in format `owner/repo`, uses the `--repo` option or `GITHUB_REPOSITORY` env variable + - `CHALLENGE_PATH` - Challenge path in format `challenges//` + - `CHALLENGE_TYPE` - Challenge instanced type + - `CHALLENGE_VERSION` - Challenge version + - `CHALLENGE_ENABLED` - Whether the challenge is enabled + - `HOST` - Hostname of challenge. Will be replaced with helm template variable `{{ .Values.kubectf.host }}` + - `CURRENT_DATE` - Current date in `%Y-%m-%d %H:%M:%S` format + + Templating is done using `{{ VARIABLE_NAME }}` syntax. - **`clean`** - Remove all generated Kubernetes files from the `k8s/` directory -- **`handout`** - Create a ZIP archive of files in the handout directory +- **`handout`** - Create a ZIP archive of files in the handout directory. + The created archive is stored in the `k8s/files/` directory as `_.zip`. It will ignore the files `.gitkeep` and `.gitignore`. **Examples:** @@ -388,7 +428,7 @@ The tools expect a specific directory structure, where challenges are stored in Inside the `challenges` directory, challenges are divided into categories. Each challenge is stored in its own directory, named identically to the challenge slug. -Besides the `challenges` directory, there is also a `template` directory, which contains the base templates for kubernetes deployment files. +Besides the `challenges` directory, there is a `template` directory, which contains the base templates for kubernetes deployment files. The structure is as follows: @@ -413,15 +453,15 @@ The structure is as follows: └── ``` -*Pages may be split into their own repository, if desired.* +*`pages` may be split into their own repository, if desired.* ### Challenge structure > [!TIP] > Challenge source code is located in the `src/` directory. -> The main files are `challenge.yml`, `description.md` and `README.md`, along with the `src/` directories. +> The main files are `challenge.yml`, `description.md` and `README.md`. -Each challenge is stored in its own directory, named after the challenge slug. +Each challenge is stored in its own directory, named identically to the challenge slug. Within the challenge directory, there are several subdirectories and files that make up the challenge. The subdirectory structure of a challenge is as follows: @@ -447,7 +487,7 @@ The subdirectory structure of a challenge is as follows: - `challenge.yml` contains the metadata for the challenge. This must be filled out by the challenge creator. Follows a very strict structure, which can be found in the schema file provided in the file. The file may be replaced by a JSON file, as `challenge.json`. - `description.md` contains the description of the challenge. This is the text that is shown to the user, when they open the challenge. It should be written in markdown. -- `README.md` contains the base idea and informationm of the challenge. May contain inspiration or other internal notes about the challenge. May also contain solution steps. +- `README.md` contains the base idea and information of the challenge. May contain inspiration or other internal notes about the challenge. May also contain solution steps. - `version` contains the version of the challenge. This is automatically updated by the `pipeline` command. Contains a single number, which is the version number of the challenge. To learn more about the `challenge.yml` file, see the [CTF Pilot's Challenge Schema](https://github.com/ctfpilot/challenge-schema). @@ -527,9 +567,9 @@ The following templates are required: - [kube-ctf](https://github.com/ctfpilot/kube-ctf) deployment template: - `instanced-k8s-challenge.yml` -Configmap tempaltes are used to generate ConfigMaps for challenges and pages. -Challenge deployment templates are used to generate the Kubernetes deployment files for challenges. -The `kube-ctf` deployment template is used to generate the deployment file for instanced challenges, when using the [kube-ctf](https://github.com/ctfpilot/kube-ctf) platform. Within this template, the challenge deployment tempalte is embedded. +**Configmap templates** are used to generate ConfigMaps for challenges and pages. +**Challenge deployment templates** are used to generate the Kubernetes deployment files for challenges. +The **`kube-ctf` deployment template** is used to generate the deployment file for instanced challenges, when using the [kube-ctf](https://github.com/ctfpilot/kube-ctf) platform. Within this template, the challenge deployment template is embedded. > [!NOTE] > Only instanced templates are currently generated. Shared templates are not yet supported. From 6e4b5920fc0e006fa49956fc3758c07d227c9ed9 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Tue, 2 Dec 2025 22:36:38 +0100 Subject: [PATCH 05/26] Add initial templates for challenge and page configurations --- README.md | 3 + src/commands/template_renderer.py | 1 + template/challenge-configmap.yml | 21 +++++ template/instanced-k8s-challenge.yml | 15 +++ template/instanced-tcp-k8s.yml | 114 +++++++++++++++++++++++ template/instanced-web-k8s.yml | 133 +++++++++++++++++++++++++++ template/page-configmap.yml | 21 +++++ 7 files changed, 308 insertions(+) create mode 100644 template/challenge-configmap.yml create mode 100644 template/instanced-k8s-challenge.yml create mode 100644 template/instanced-tcp-k8s.yml create mode 100644 template/instanced-web-k8s.yml create mode 100644 template/page-configmap.yml diff --git a/README.md b/README.md index 89a0a3f..b8e2350 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ You can then run the tool using python: python challenge-toolkit/src/ctf.py [arguments] [options] ``` +> [!IMPORTANT] +> Deployment templates are essentioal for a number of commands to work properly. + In order to use `create`, `template`, and `page` you need to copy the deployment templates into the `template/` directory of your challenge repository (In acordance with the **[Template structure](#template-structure)** section). This can be done by running: diff --git a/src/commands/template_renderer.py b/src/commands/template_renderer.py index a66a748..35692d6 100644 --- a/src/commands/template_renderer.py +++ b/src/commands/template_renderer.py @@ -145,6 +145,7 @@ def render(self, args: Args): output_content = K8s.replace_templated("CHALLENGE_VERSION", str(args.challenge.get_version()), output_content) output_content = K8s.replace_templated("CHALLENGE_EXPIRES", str(args.expires), output_content) output_content = K8s.replace_templated("CHALLENGE_AVAILABLE_AT", str(args.available), output_content) + output_content = K8s.replace_templated("CHALLENGE_REPO", args.repo, output_content) # Create docker image name docker_image = f"{args.challenge.category}-{args.challenge.slug}".lower().replace(" ", "") diff --git a/template/challenge-configmap.yml b/template/challenge-configmap.yml new file mode 100644 index 0000000..4c726fd --- /dev/null +++ b/template/challenge-configmap.yml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "challenge-{{ CHALLENGE_CATEGORY }}-{{ CHALLENGE_NAME }}" + labels: + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + challenges.ctfpilot.com/version: "{{ CHALLENGE_VERSION }}" + challenges.ctfpilot.com/configmap: "challenge-config" + challenges.ctfpilot.com/enabled: "{{ CHALLENGE_ENABLED }}" + ctfpilot.com/component: "challenge-config" +data: + name: "{{ CHALLENGE_NAME }}" + path: "{{ CHALLENGE_PATH }}" + repository: "{{ CHALLENGE_REPO }}" + generated_at: "{{ CURRENT_DATE }}" + challenge: | + %%CONFIG%% + description: | + %%DESCRIPTION%% diff --git a/template/instanced-k8s-challenge.yml b/template/instanced-k8s-challenge.yml new file mode 100644 index 0000000..b1b84d2 --- /dev/null +++ b/template/instanced-k8s-challenge.yml @@ -0,0 +1,15 @@ +apiVersion: ctfpilot.com/v1 +kind: instancedChallenge +metadata: + name: "{{ CHALLENGE_NAME }}" + labels: + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + ctfpilot.com/component: "instanced-challenge" +spec: + expires: {{ CHALLENGE_EXPIRES }} + available_at: {{ CHALLENGE_AVAILABLE_AT }} + type: {{ CHALLENGE_TYPE }} + template: | + %%TEMPLATE%% diff --git a/template/instanced-tcp-k8s.yml b/template/instanced-tcp-k8s.yml new file mode 100644 index 0000000..4b645ce --- /dev/null +++ b/template/instanced-tcp-k8s.yml @@ -0,0 +1,114 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "ctf-{{ deployment_id }}" + namespace: kubectf-challenges-instanced + labels: + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + ctfpilot.com/component: "challenge" + annotations: + janitor/expires: "{{ expires }}" +spec: + replicas: 1 + selector: + matchLabels: + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + template: + metadata: + labels: + role: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + ctfpilot.com/component: "challenge" + spec: + enableServiceLinks: false + automountServiceAccountToken: false + imagePullSecrets: + - name: dockerconfigjson-github-com + dnsPolicy: None + dnsConfig: + nameservers: + - 1.1.1.1 + - 8.8.8.8 + tolerations: + - key: "ctfpilot.com/node" + value: "scaler" + effect: "PreferNoSchedule" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: "ctfpilot.com/node" + operator: In + values: + - scaler + containers: + - name: web + image: ghcr.io/{{ CHALLENGE_REPO }}-{{ DOCKER_IMAGE }}:{{ CHALLENGE_VERSION }} + imagePullPolicy: IfNotPresent + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 10m + memory: 32Mi + ports: + - containerPort: 8080 + name: tcp +--- +apiVersion: v1 +kind: Service +metadata: + name: "ctf-{{ deployment_id }}" + namespace: kubectf-challenges-instanced + labels: + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + ctfpilot.com/component: "instanced-challenge" + annotations: + janitor/expires: "{{ expires }}" +spec: + selector: + role: "{{ CHALLENGE_TYPE }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + ports: + - port: 8080 + name: tcp +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRouteTCP +metadata: + name: "ingress-ctf-{{ deployment_id }}" + namespace: kubectf-challenges-instanced + labels: + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + ctfpilot.com/component: "instanced-challenge" + annotations: + janitor/expires: "{{ expires }}" + traefik.ingress.kubernetes.io/router.priority: "100" +spec: + entryPoints: + - websecure + routes: + - match: HostSNI(`{{ deployment_id }}.{{ domain }}`) + services: + - name: "ctf-{{ deployment_id }}" + port: 8080 + tls: + secretName: kubectf-cert-challs diff --git a/template/instanced-web-k8s.yml b/template/instanced-web-k8s.yml new file mode 100644 index 0000000..eb7c1f0 --- /dev/null +++ b/template/instanced-web-k8s.yml @@ -0,0 +1,133 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "ctf-{{ deployment_id }}" + namespace: kubectf-challenges-instanced + labels: + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + ctfpilot.com/component: "instanced-challenge" + annotations: + janitor/expires: "{{ expires }}" +spec: + replicas: 1 + selector: + matchLabels: + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + template: + metadata: + labels: + role: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + ctfpilot.com/component: "instanced-challenge" + spec: + enableServiceLinks: false + automountServiceAccountToken: false + imagePullSecrets: + - name: dockerconfigjson-github-com + dnsPolicy: None + dnsConfig: + nameservers: + - 1.1.1.1 + - 8.8.8.8 + tolerations: + - key: "ctfpilot.com/node" + value: "scaler" + effect: "PreferNoSchedule" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: "ctfpilot.com/node" + operator: In + values: + - scaler + containers: + - name: web + image: ghcr.io/{{ CHALLENGE_REPO }}-{{ DOCKER_IMAGE }}:{{ CHALLENGE_VERSION }} + imagePullPolicy: IfNotPresent + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 10m + memory: 32Mi + ports: + - containerPort: 80 + name: web-port + startupProbe: + httpGet: + path: / + port: web-port + failureThreshold: 12 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: web-port + initialDelaySeconds: 5 + timeoutSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: "ctf-{{ deployment_id }}" + namespace: kubectf-challenges-instanced + labels: + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + ctfpilot.com/component: "instanced-challenge" + annotations: + janitor/expires: "{{ expires }}" +spec: + selector: + role: "{{ CHALLENGE_TYPE }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + ports: + - port: 80 + targetPort: web-port +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-ctf-{{ deployment_id }} + namespace: kubectf-challenges-instanced + labels: + challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" + challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" + instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" + ctfpilot.com/component: "instanced-challenge" + annotations: + janitor/expires: "{{ expires }}" + traefik.ingress.kubernetes.io/router.middlewares: kubectf-instancing-fallback@kubernetescrd +spec: + tls: + - hosts: + - "{{ deployment_id }}.{{ domain }}" + secretName: kubectf-cert-challs + rules: + - host: "{{ deployment_id }}.{{ domain }}" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ctf-{{ deployment_id }} + port: + number: 80 diff --git a/template/page-configmap.yml b/template/page-configmap.yml new file mode 100644 index 0000000..e805894 --- /dev/null +++ b/template/page-configmap.yml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "page-{{ PAGE_SLUG }}" + labels: + page.ctfpilot.com/slug: "{{ PAGE_NAME }}" + page.ctfpilot.com/version: "{{ PAGE_VERSION }}" + page.ctfpilot.com/enabled: "{{ PAGE_ENABLED }}" + page.ctfpilot.com/configmap: "page-config" + challenges.ctfpilot.com/configmap: "page-config" + ctfpilot.com/component: "page-config" +data: + slug: "{{ PAGE_SLUG }}" + name: "{{ PAGE_NAME }}" + path: "{{ PAGE_PATH }}" + repository: "{{ PAGE_REPO }}" + generated_at: "{ { CURRENT_DATE } }" + page: | + %%PAGE%% + content: | + %%CONTENT%% From db28b5676e8ef694ba88263c772eff0356943f00 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Tue, 2 Dec 2025 22:50:47 +0100 Subject: [PATCH 06/26] Fix comparison checks for None in challenge_creator.py --- src/commands/challenge_creator.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/commands/challenge_creator.py b/src/commands/challenge_creator.py index a9d380d..bb9a4a0 100644 --- a/src/commands/challenge_creator.py +++ b/src/commands/challenge_creator.py @@ -62,7 +62,7 @@ def prompt(self, challenge: Challenge): except ValueError: pass - if args.slug == None: + if args.slug is None: while True: try: challenge.set_slug(input(f"Slug of the challenge ({Utils.slugify(challenge.name)}): ") or Utils.slugify(challenge.name) or "challenge") @@ -70,7 +70,7 @@ def prompt(self, challenge: Challenge): except ValueError: pass - if args.author == None: + if args.author is None: while True: try: challenge.set_author(input("Author of the challenge: ")) @@ -78,7 +78,7 @@ def prompt(self, challenge: Challenge): except ValueError: pass - if args.category == None: + if args.category is None: while True: try: challenge.set_category(input(f"Category of the challenge ({', '.join(CATEGORIES)}): ").lower()) @@ -86,7 +86,7 @@ def prompt(self, challenge: Challenge): except ValueError: pass - if args.difficulty == None: + if args.difficulty is None: while True: try: challenge.set_difficulty(input(f"Difficulty of the challenge ({', '.join(DIFFICULTIES)}): ").lower()) @@ -95,7 +95,7 @@ def prompt(self, challenge: Challenge): pass prompted_type = None - if args.type == None: + if args.type is None: while True: try: prompted_type = input(f"Type of the challenge ({', '.join(CHALL_TYPES)}): ").lower() @@ -104,7 +104,7 @@ def prompt(self, challenge: Challenge): except ValueError: pass - if args.flag == None: + if args.flag is None: while True: try: challenge.set_flag(input(f"Flag for the challenge ({FLAG_FORMAT}): ")) @@ -112,7 +112,7 @@ def prompt(self, challenge: Challenge): except ValueError: pass - if args.points == None: + if args.points is None: while True: try: challenge.set_points(int(input("Points for the challenge (1000): ") or 1000)) @@ -120,7 +120,7 @@ def prompt(self, challenge: Challenge): except ValueError: pass - if args.min_points == None: + if args.min_points is None: while True: try: challenge.set_min_points(int(input("Minimum points for the challenge (100): ") or 100 )) @@ -148,7 +148,7 @@ def prompt(self, challenge: Challenge): else: challenge.set_description_location(args.description_location) - if args.dockerfile_location == None or args.dockerfile_location == "src/Dockerfile": + if args.dockerfile_location is None or args.dockerfile_location == "src/Dockerfile": contains_docker = input("Does the challenge contain a Dockerfile? (y/N): ").lower() == "y" if contains_docker: while True: @@ -209,8 +209,7 @@ def run(self): if args.slug is None: args.slug = Utils.slugify(args.name) if args.name else "challenge" - challenge = None - challenge = challenge = Challenge( + challenge = Challenge( name = args.name, slug = args.slug, author = args.author, From 73f73b7b765380d2037474fab4a00461bd39ae96 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Tue, 2 Dec 2025 22:53:50 +0100 Subject: [PATCH 07/26] Add input validation and error messages for challenge parameters in Args class --- src/commands/challenge_creator.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/commands/challenge_creator.py b/src/commands/challenge_creator.py index bb9a4a0..7922209 100644 --- a/src/commands/challenge_creator.py +++ b/src/commands/challenge_creator.py @@ -60,6 +60,7 @@ def prompt(self, challenge: Challenge): challenge.set_name(input("Name of the challenge: ")) break except ValueError: + print("Invalid name. Please try again.") pass if args.slug is None: @@ -68,6 +69,7 @@ def prompt(self, challenge: Challenge): challenge.set_slug(input(f"Slug of the challenge ({Utils.slugify(challenge.name)}): ") or Utils.slugify(challenge.name) or "challenge") break except ValueError: + print("Invalid slug. Please try again.") pass if args.author is None: @@ -76,6 +78,7 @@ def prompt(self, challenge: Challenge): challenge.set_author(input("Author of the challenge: ")) break except ValueError: + print("Invalid author. Please try again.") pass if args.category is None: @@ -84,6 +87,7 @@ def prompt(self, challenge: Challenge): challenge.set_category(input(f"Category of the challenge ({', '.join(CATEGORIES)}): ").lower()) break except ValueError: + print("Invalid category. Please try again.") pass if args.difficulty is None: @@ -92,6 +96,7 @@ def prompt(self, challenge: Challenge): challenge.set_difficulty(input(f"Difficulty of the challenge ({', '.join(DIFFICULTIES)}): ").lower()) break except ValueError: + print("Invalid difficulty. Please try again.") pass prompted_type = None @@ -102,6 +107,7 @@ def prompt(self, challenge: Challenge): challenge.set_type(prompted_type) break except ValueError: + print("Invalid type. Please try again.") pass if args.flag is None: @@ -110,6 +116,7 @@ def prompt(self, challenge: Challenge): challenge.set_flag(input(f"Flag for the challenge ({FLAG_FORMAT}): ")) break except ValueError: + print("Invalid flag. Please try again.") pass if args.points is None: @@ -118,6 +125,7 @@ def prompt(self, challenge: Challenge): challenge.set_points(int(input("Points for the challenge (1000): ") or 1000)) break except ValueError: + print("Invalid points. Please try again.") pass if args.min_points is None: @@ -126,6 +134,7 @@ def prompt(self, challenge: Challenge): challenge.set_min_points(int(input("Minimum points for the challenge (100): ") or 100 )) break except ValueError: + print("Invalid minimum points. Please try again.") pass if (args.type == "instanced" or prompted_type == "instanced") and args.instanced_type == "none": @@ -134,6 +143,7 @@ def prompt(self, challenge: Challenge): challenge.set_instanced_type(input(f"Type of instanced challenge ({', '.join(INSTANCED_TYPES)}): ").lower()) break except ValueError: + print("Invalid instanced type. Please try again.") pass else: challenge.set_instanced_type("none") @@ -144,6 +154,7 @@ def prompt(self, challenge: Challenge): challenge.set_description_location(input("Location of the description file (description.md): ") or "description.md") break except ValueError: + print("Invalid description location. Please try again.") pass else: challenge.set_description_location(args.description_location) @@ -160,6 +171,7 @@ def prompt(self, challenge: Challenge): challenge.add_dockerfile_location([ DockerfileLocation(dockerfile_location, dockerfile_context, dockerfile_identifier) ]) break except ValueError: + print("Invalid Dockerfile location. Please try again.") pass if args.handout_location == "handout": @@ -197,7 +209,6 @@ def run(self): self.args = arguments else: self.args.parse() - self.args = self.args arguments = self.args args = self.args.args From 8f436099104c320a82700d2bd63bbc2a59e69dcc Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Tue, 2 Dec 2025 22:58:22 +0100 Subject: [PATCH 08/26] Fix typos and improve argument parsing in various modules --- README.md | 4 ++-- src/commands/page.py | 3 +-- src/commands/pipeline.py | 2 -- src/commands/slugify.py | 6 +----- src/commands/template_renderer.py | 3 +-- src/library/data.py | 8 ++++---- src/library/generator.py | 2 +- src/library/utils.py | 4 ++-- src/test.py | 1 - template/page-configmap.yml | 2 +- 10 files changed, 13 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b8e2350..380c63e 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ python challenge-toolkit/src/ctf.py [arguments] [options] ``` > [!IMPORTANT] -> Deployment templates are essentioal for a number of commands to work properly. +> Deployment templates are essential for a number of commands to work properly. -In order to use `create`, `template`, and `page` you need to copy the deployment templates into the `template/` directory of your challenge repository (In acordance with the **[Template structure](#template-structure)** section). +In order to use `create`, `template`, and `page` you need to copy the deployment templates into the `template/` directory of your challenge repository (In accordance with the **[Template structure](#template-structure)** section). This can be done by running: diff --git a/src/commands/page.py b/src/commands/page.py index 5043228..f81291f 100644 --- a/src/commands/page.py +++ b/src/commands/page.py @@ -23,7 +23,7 @@ def __init__(self, parent_parser = None): self.parser = argparse.ArgumentParser(description="Render template for CTFd pages") self.parser.add_argument("page", help="Page to render (directory for page - 'web/example')") - self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY ", "")) + self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY", "")) def parse(self): if self.subcommand: @@ -176,7 +176,6 @@ def run(self): self.args = arguments else: self.args.parse() - self.args = self.args args = self.args diff --git a/src/commands/pipeline.py b/src/commands/pipeline.py index 8ade0d8..4c28ac7 100644 --- a/src/commands/pipeline.py +++ b/src/commands/pipeline.py @@ -1,4 +1,3 @@ -import os import sys import argparse import subprocess @@ -89,7 +88,6 @@ def run(self): self.args = arguments else: self.args.parse() - self.args = self.args args = self.args.args diff --git a/src/commands/slugify.py b/src/commands/slugify.py index 0c834d5..2793b80 100644 --- a/src/commands/slugify.py +++ b/src/commands/slugify.py @@ -1,9 +1,6 @@ -import os import sys import argparse -from datetime import datetime - from library.utils import Utils class Args: @@ -18,7 +15,7 @@ def __init__(self, parent_parser = None): else: self.parser = argparse.ArgumentParser(description="Slugify a string for use in challenge slug") - self.parser.add_argument("name", help="Name to slugify", required=True) + self.parser.add_argument("name", help="Name to slugify") def parse(self): if self.subcommand: @@ -57,7 +54,6 @@ def run(self): self.args = arguments else: self.args.parse() - self.args = self.args args = self.args diff --git a/src/commands/template_renderer.py b/src/commands/template_renderer.py index 35692d6..9e7fe3a 100644 --- a/src/commands/template_renderer.py +++ b/src/commands/template_renderer.py @@ -30,7 +30,7 @@ def __init__(self, parent_parser = None): self.parser.add_argument("challenge", help="Challenge to run (directory for challenge - 'web/example')") self.parser.add_argument("--expires", help="Time until challenge expires", type=int, default=3600) self.parser.add_argument("--available", help="Time until challenge is available", type=int, default=0) - self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY ", "")) + self.parser.add_argument("--repo", help="GitHub repository for CTFd pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY", "")) def parse(self): if self.subcommand: @@ -373,7 +373,6 @@ def run(self): self.args = arguments else: self.args.parse() - self.args = self.args args = self.args diff --git a/src/library/data.py b/src/library/data.py index 4b30210..7256199 100644 --- a/src/library/data.py +++ b/src/library/data.py @@ -38,7 +38,7 @@ def set_context(self, context: str): def set_identifier(self, identifier: Optional[str]): identifier = Utils.slugify(identifier) or None - if (identifier == None): + if identifier is None: self.identifier = None return @@ -208,7 +208,7 @@ def set_category(self, category: str): self.category = category def set_difficulty(self, difficulty: str): - if difficulty == None: + if difficulty is None: print("Difficulty must be provided.") raise ValueError("Difficulty must be provided.") @@ -220,7 +220,7 @@ def set_difficulty(self, difficulty: str): self.difficulty = difficulty def set_type(self, type: str): - if type == None: + if type is None: print("Type must be provided.") raise ValueError("Type must be provided.") @@ -369,7 +369,7 @@ def add_prerequisite(self, prerequisite: Optional[str]): print(f"Prerequisite {prerequisite} already exists.") raise ValueError("Prerequisite already exists.") - if prerequisite == None: + if prerequisite is None: print("Prerequisite must be provided.") raise ValueError("Prerequisite must be provided.") diff --git a/src/library/generator.py b/src/library/generator.py index 0d6a91c..58cb6d3 100644 --- a/src/library/generator.py +++ b/src/library/generator.py @@ -206,7 +206,7 @@ def readme_file(self): with open(path, "w") as f: f.write(f"# {self.challenge.name}\n\n") f.write("*Add information about challenge here* \n") - f.write("*It is meant to contian internal documentation of the challenge, such as how it is solved*\n") + f.write("*It is meant to contain internal documentation of the challenge, such as how it is solved*\n") print(f"File created: {path}") diff --git a/src/library/utils.py b/src/library/utils.py index 3838e27..f9f121e 100644 --- a/src/library/utils.py +++ b/src/library/utils.py @@ -56,14 +56,14 @@ def get_template_dir() -> Path: @staticmethod def slugify(text): - if (text == None): + if text is None: return None return slugify(text.strip()).strip('-').strip('_').strip('.') @staticmethod def validate_length(text, min_length, max_length, identifier): - if text == None: + if text is None: print(f"{identifier.capitalize()} must be provided.") return False diff --git a/src/test.py b/src/test.py index 858ae1f..3185137 100644 --- a/src/test.py +++ b/src/test.py @@ -1,6 +1,5 @@ import unittest import io -import os from contextlib import redirect_stdout from tests.library.dataTest import TestChallenge, TestChallengeFileLoad, TestChallengeFileWrite, TestPage diff --git a/template/page-configmap.yml b/template/page-configmap.yml index e805894..ed4b958 100644 --- a/template/page-configmap.yml +++ b/template/page-configmap.yml @@ -14,7 +14,7 @@ data: name: "{{ PAGE_NAME }}" path: "{{ PAGE_PATH }}" repository: "{{ PAGE_REPO }}" - generated_at: "{ { CURRENT_DATE } }" + generated_at: "{{ CURRENT_DATE }}" page: | %%PAGE%% content: | From 71fb89099133b034ee5c3196571c943a80623402 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Tue, 2 Dec 2025 23:06:39 +0100 Subject: [PATCH 09/26] Refactor input validation in Args class and update component labels in Kubernetes templates --- src/commands/challenge_creator.py | 12 ------------ template/instanced-tcp-k8s.yml | 5 +++-- template/instanced-web-k8s.yml | 6 +++--- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/commands/challenge_creator.py b/src/commands/challenge_creator.py index 7922209..e90ec1d 100644 --- a/src/commands/challenge_creator.py +++ b/src/commands/challenge_creator.py @@ -61,7 +61,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid name. Please try again.") - pass if args.slug is None: while True: @@ -70,7 +69,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid slug. Please try again.") - pass if args.author is None: while True: @@ -79,7 +77,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid author. Please try again.") - pass if args.category is None: while True: @@ -88,7 +85,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid category. Please try again.") - pass if args.difficulty is None: while True: @@ -97,7 +93,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid difficulty. Please try again.") - pass prompted_type = None if args.type is None: @@ -108,7 +103,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid type. Please try again.") - pass if args.flag is None: while True: @@ -117,7 +111,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid flag. Please try again.") - pass if args.points is None: while True: @@ -126,7 +119,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid points. Please try again.") - pass if args.min_points is None: while True: @@ -135,7 +127,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid minimum points. Please try again.") - pass if (args.type == "instanced" or prompted_type == "instanced") and args.instanced_type == "none": while True: @@ -144,7 +135,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid instanced type. Please try again.") - pass else: challenge.set_instanced_type("none") @@ -155,7 +145,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid description location. Please try again.") - pass else: challenge.set_description_location(args.description_location) @@ -172,7 +161,6 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid Dockerfile location. Please try again.") - pass if args.handout_location == "handout": contains_handout = input("What is the location of the handout for handing out with the challenge? (handout): ") or "handout" diff --git a/template/instanced-tcp-k8s.yml b/template/instanced-tcp-k8s.yml index 4b645ce..fd99168 100644 --- a/template/instanced-tcp-k8s.yml +++ b/template/instanced-tcp-k8s.yml @@ -9,7 +9,7 @@ metadata: challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" - ctfpilot.com/component: "challenge" + ctfpilot.com/component: "instanced-challenge" annotations: janitor/expires: "{{ expires }}" spec: @@ -27,7 +27,7 @@ spec: challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" - ctfpilot.com/component: "challenge" + ctfpilot.com/component: "instanced-challenge" spec: enableServiceLinks: false automountServiceAccountToken: false @@ -96,6 +96,7 @@ metadata: labels: challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" ctfpilot.com/component: "instanced-challenge" diff --git a/template/instanced-web-k8s.yml b/template/instanced-web-k8s.yml index eb7c1f0..64e7f18 100644 --- a/template/instanced-web-k8s.yml +++ b/template/instanced-web-k8s.yml @@ -6,7 +6,7 @@ metadata: labels: challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" - ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" ctfpilot.com/component: "instanced-challenge" @@ -86,7 +86,7 @@ metadata: labels: challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" - ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" ctfpilot.com/component: "instanced-challenge" @@ -108,7 +108,7 @@ metadata: labels: challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" - ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" ctfpilot.com/component: "instanced-challenge" From 5fddb4ec67426d879df1c8b8e6218669e6b8feb2 Mon Sep 17 00:00:00 2001 From: Mikkel Albrechtsen Date: Tue, 2 Dec 2025 23:14:30 +0100 Subject: [PATCH 10/26] Update src/commands/pipeline.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/pipeline.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/commands/pipeline.py b/src/commands/pipeline.py index 4c28ac7..f920729 100644 --- a/src/commands/pipeline.py +++ b/src/commands/pipeline.py @@ -50,23 +50,31 @@ def build(registry: str, image_prefix: str, image_suffix: str, challenge: Challe print(f"Building Docker image \"{image_full}\"...") try: - command = f"docker build -t {image_full}:latest -t {image_full}:{challenge.get_version()} -f {Utils.get_challenge_dir(challenge.category, challenge.slug)}/{dockerfile_location.location} {Utils.get_challenge_dir(challenge.category, challenge.slug)}/{dockerfile_location.context}" - build_proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + build_command = [ + "docker", "build", + "-t", f"{image_full}:latest", + "-t", f"{image_full}:{challenge.get_version()}", + "-f", f"{Utils.get_challenge_dir(challenge.category, challenge.slug)}/{dockerfile_location.location}", + f"{Utils.get_challenge_dir(challenge.category, challenge.slug)}/{dockerfile_location.context}" + ] + build_proc = subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if build_proc.stdout is not None: for line in build_proc.stdout: print(line, end="") build_proc.wait() if build_proc.returncode != 0: - raise subprocess.CalledProcessError(build_proc.returncode, command) + raise subprocess.CalledProcessError(build_proc.returncode, build_command) - command = f"docker push {image_full} --all-tags" - push_proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + push_command = [ + "docker", "push", image_full, "--all-tags" + ] + push_proc = subprocess.Popen(push_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) if push_proc.stdout is not None: for line in push_proc.stdout: print(line, end="") push_proc.wait() if push_proc.returncode != 0: - raise subprocess.CalledProcessError(push_proc.returncode, command) + raise subprocess.CalledProcessError(push_proc.returncode, push_command) except subprocess.CalledProcessError as e: print(f"Error: Command failed with exit code {e.returncode}: {e.cmd}", file=sys.stderr) raise e From ee33df51193f80449e96f829b0b370057e637869 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Tue, 2 Dec 2025 23:14:52 +0100 Subject: [PATCH 11/26] Fix label key for category in Kubernetes deployment template --- template/instanced-web-k8s.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/instanced-web-k8s.yml b/template/instanced-web-k8s.yml index 64e7f18..8caf34d 100644 --- a/template/instanced-web-k8s.yml +++ b/template/instanced-web-k8s.yml @@ -24,7 +24,7 @@ spec: role: "{{ CHALLENGE_TYPE }}" challenges.ctfpilot.com/type: "{{ CHALLENGE_TYPE }}" challenges.ctfpilot.com/name: "{{ CHALLENGE_NAME }}" - ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" + challenges.ctfpilot.com/category: "{{ CHALLENGE_CATEGORY }}" instanced.challenges.ctfpilot.com/deployment: "{{ deployment_id }}" instanced.challenges.ctfpilot.com/owner: "{{ owner_id }}" ctfpilot.com/component: "instanced-challenge" From bb4f6ebf006c893a6ac8adfee490dc7f4be5ad52 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Tue, 2 Dec 2025 23:15:55 +0100 Subject: [PATCH 12/26] Fix prerequisite validation order in Challenge class --- src/library/data.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/library/data.py b/src/library/data.py index 7256199..67f0429 100644 --- a/src/library/data.py +++ b/src/library/data.py @@ -365,14 +365,14 @@ def add_prerequisite(self, prerequisite: Optional[str]): if Utils.validate_length(prerequisite, 1, 50, "prerequisite") == False: raise ValueError("Prerequisite must be between 1 and 50 characters.") - if prerequisite in self.prerequisites: - print(f"Prerequisite {prerequisite} already exists.") - raise ValueError("Prerequisite already exists.") - if prerequisite is None: print("Prerequisite must be provided.") raise ValueError("Prerequisite must be provided.") + if prerequisite in self.prerequisites: + print(f"Prerequisite {prerequisite} already exists.") + raise ValueError("Prerequisite already exists.") + self.prerequisites.append(prerequisite) def get_version(self): From d67d8ca82c9941727f13db8f76d3d9df4e81c500 Mon Sep 17 00:00:00 2001 From: Mikkel Albrechtsen Date: Tue, 2 Dec 2025 23:21:29 +0100 Subject: [PATCH 13/26] Update src/library/data.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/library/data.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/library/data.py b/src/library/data.py index 67f0429..26ff9d4 100644 --- a/src/library/data.py +++ b/src/library/data.py @@ -115,58 +115,58 @@ def __init__( handout_dir: Optional[str] = None ): # Insert default values from DEFAULT - if enabled != None: + if enabled is not None: self.set_enabled(enabled) else: self.set_enabled(DEFAULT['enabled']) - if name != None: + if name is not None: self.set_name(name) else: self.set_name(DEFAULT['name']) - if slug != None: + if slug is not None: self.set_slug(slug) else: self.set_slug(DEFAULT['slug']) - if author != None: + if author is not None: self.set_author(author) else: self.set_author(DEFAULT['author']) - if category != None: + if category is not None: self.set_category(category) else: self.set_category(DEFAULT['category']) - if difficulty != None: + if difficulty is not None: self.set_difficulty(difficulty) else: self.set_difficulty(DEFAULT['difficulty']) - if type != None: + if type is not None: self.set_type(type) else: self.set_type(DEFAULT['type']) - if instanced_type != None: + if instanced_type is not None: self.set_instanced_type(instanced_type) else: self.set_instanced_type(DEFAULT['instanced_type']) - if tags != None: + if tags is not None: self.set_tags(tags) else: self.set_tags(DEFAULT['tags']) - if instanced_name != None: + if instanced_name is not None: self.instanced_name = instanced_name else: self.instanced_name = DEFAULT['instanced_name'] - if instanced_subdomains != None: + if instanced_subdomains is not None: self.set_instanced_subdomains(instanced_subdomains) else: self.instanced_subdomains = DEFAULT['instanced_subdomains'] - if connection != None: + if connection is not None: self.set_connection(connection) else: self.connection = DEFAULT['connection'] - if flag != None: + if flag is not None: self.set_flag(flag) else: self.set_flag(DEFAULT['flag']) - if points != None: + if points is not None: self.set_points(points) else: self.set_points(DEFAULT['points']) - if decay != None: + if decay is not None: self.set_decay(decay) else: self.set_decay(DEFAULT['decay']) - if min_points != None: + if min_points is not None: self.set_min_points(min_points) else: self.set_min_points(DEFAULT['min_points']) - if description_location != None: + if description_location is not None: self.set_description_location(description_location) else: self.set_description_location(DEFAULT['description_location']) - if handout_dir != None: + if handout_dir is not None: self.set_handout_dir(handout_dir) else: self.set_handout_dir(DEFAULT['handout_dir']) From 4f67208be2e3cdcc3b075215f2fe575786872068 Mon Sep 17 00:00:00 2001 From: Mikkel Albrechtsen Date: Tue, 2 Dec 2025 23:21:50 +0100 Subject: [PATCH 14/26] Update src/library/config.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/library/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/library/config.py b/src/library/config.py index bd72fae..b3094be 100644 --- a/src/library/config.py +++ b/src/library/config.py @@ -9,7 +9,7 @@ # Allowed values for schema fields CHALL_TYPES = [ "static", "shared", "instanced" ] -DIFFICULTIES = [ "beginner", "easy", "easy-medium", "medium", "medium-hard","hard", "very-hard", "insane"] +DIFFICULTIES = [ "beginner", "easy", "easy-medium", "medium", "medium-hard", "hard", "very-hard", "insane"] CATEGORIES = [ "web", "forensics", "rev", "crypto", "pwn", "boot2root", "osint", "misc", "blockchain", "mobile", "test" ] INSTANCED_TYPES = [ "none", "web", "tcp" ] # "none" is the default. Defines how users interact with the challenge. From 71b17cbd3338aa351830c8173440d0305b52ee81 Mon Sep 17 00:00:00 2001 From: Mikkel Albrechtsen Date: Tue, 2 Dec 2025 23:22:15 +0100 Subject: [PATCH 15/26] Update src/test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test.py b/src/test.py index 3185137..93aef1f 100644 --- a/src/test.py +++ b/src/test.py @@ -6,5 +6,5 @@ if __name__ == '__main__': - with io.StringIO() as buf, redirect_stdout(buf): + with io.StringIO() as buf, redirect_stdout(buf): unittest.main() From 65742d7204233711c163db6b9a31d0541026a71b Mon Sep 17 00:00:00 2001 From: Mikkel Albrechtsen Date: Tue, 2 Dec 2025 23:22:58 +0100 Subject: [PATCH 16/26] Update src/commands/pipeline.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/pipeline.py b/src/commands/pipeline.py index f920729..196a077 100644 --- a/src/commands/pipeline.py +++ b/src/commands/pipeline.py @@ -105,7 +105,7 @@ def run(self): challenge = args.challenge - if not "/" in challenge: + if "/" not in challenge: print(f"Challenge {challenge} must be in the format 'category/name'") exit(1) From 6fd6f347bf741b91fd032cca16e43e1dd8947382 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Wed, 3 Dec 2025 22:48:59 +0100 Subject: [PATCH 17/26] Refactor K8s class to use Renderer for templating and clean up redundant methods --- src/commands/template_renderer.py | 53 ++++++++++++++----------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/src/commands/template_renderer.py b/src/commands/template_renderer.py index 9e7fe3a..b63d775 100644 --- a/src/commands/template_renderer.py +++ b/src/commands/template_renderer.py @@ -92,11 +92,7 @@ def run(self): print(f"Cleaned instanced template for {self.challenge.slug}") -class K8s: - def __init__(self, challenge: Challenge): - self.challenge = challenge - self.generator = Generator(challenge) - +class Renderer: @staticmethod def replace_templated(key: str, value: str, content: str): content = content.replace("{{ " + key + " }}", value) @@ -104,6 +100,12 @@ def replace_templated(key: str, value: str, content: str): content = content.replace("{ { " + key + " } }", value) content = content.replace("{ {" + key + "} }", value) return content + + +class K8s: + def __init__(self, challenge: Challenge): + self.challenge = challenge + self.generator = Generator(challenge) def get_template_content(self): template_source_path = os.path.join(Utils.get_template_dir(), "instanced-k8s-challenge.yml") @@ -139,17 +141,17 @@ def render(self, args: Args): output_content = templateing_base_template.replace(" %%TEMPLATE%%", challenge_template_indented) - output_content = K8s.replace_templated("CHALLENGE_NAME", args.challenge.slug, output_content) - output_content = K8s.replace_templated("CHALLENGE_CATEGORY", args.challenge.category, output_content) - output_content = K8s.replace_templated("CHALLENGE_TYPE", args.challenge.instanced_type, output_content) - output_content = K8s.replace_templated("CHALLENGE_VERSION", str(args.challenge.get_version()), output_content) - output_content = K8s.replace_templated("CHALLENGE_EXPIRES", str(args.expires), output_content) - output_content = K8s.replace_templated("CHALLENGE_AVAILABLE_AT", str(args.available), output_content) - output_content = K8s.replace_templated("CHALLENGE_REPO", args.repo, output_content) + output_content = Renderer.replace_templated("CHALLENGE_NAME", args.challenge.slug, output_content) + output_content = Renderer.replace_templated("CHALLENGE_CATEGORY", args.challenge.category, output_content) + output_content = Renderer.replace_templated("CHALLENGE_TYPE", args.challenge.instanced_type, output_content) + output_content = Renderer.replace_templated("CHALLENGE_VERSION", str(args.challenge.get_version()), output_content) + output_content = Renderer.replace_templated("CHALLENGE_EXPIRES", str(args.expires), output_content) + output_content = Renderer.replace_templated("CHALLENGE_AVAILABLE_AT", str(args.available), output_content) + output_content = Renderer.replace_templated("CHALLENGE_REPO", args.repo, output_content) # Create docker image name docker_image = f"{args.challenge.category}-{args.challenge.slug}".lower().replace(" ", "") - output_content = K8s.replace_templated("DOCKER_IMAGE", docker_image, output_content) + output_content = Renderer.replace_templated("DOCKER_IMAGE", docker_image, output_content) deployment_dir = Utils.get_challenge_render_dir(args.challenge.category, args.challenge.slug) if not os.path.exists(deployment_dir): @@ -201,13 +203,6 @@ class ConfigMap: def __init__(self, challenge: Challenge): self.challenge = challenge - - def replace_templated(self, key: str, value: str, content: str): - content = content.replace("{{ " + key + " }}", value) - content = content.replace("{{" + key + "}}", value) - content = content.replace("{ { " + key + " } }", value) - content = content.replace("{ {" + key + "} }", value) - return content def get_template_content(self): template_source = self.challenge.str_json(CHALLENGE_SCHEMA) @@ -236,19 +231,19 @@ def render(self, args: Args): output_content = output_content.replace(" %%DESCRIPTION%%", self.get_description()) # Template values in configmap - output_content = self.replace_templated("CHALLENGE_NAME", args.challenge.slug, output_content) - output_content = self.replace_templated("CHALLENGE_PATH", Utils.get_challenge_dir_str(self.challenge.category, self.challenge.slug), output_content) - output_content = self.replace_templated("CHALLENGE_REPO", args.repo, output_content) - output_content = self.replace_templated("CHALLENGE_CATEGORY", args.challenge.category, output_content) - output_content = self.replace_templated("CHALLENGE_TYPE", args.challenge.instanced_type, output_content) - output_content = self.replace_templated("CHALLENGE_VERSION", str(args.challenge.get_version()), output_content) - output_content = self.replace_templated("CHALLENGE_ENABLED", str(args.challenge.enabled).lower(), output_content) - output_content = self.replace_templated("HOST", "{{ .Values.kubectf.host }}", output_content) + output_content = Renderer.replace_templated("CHALLENGE_NAME", args.challenge.slug, output_content) + output_content = Renderer.replace_templated("CHALLENGE_PATH", Utils.get_challenge_dir_str(self.challenge.category, self.challenge.slug), output_content) + output_content = Renderer.replace_templated("CHALLENGE_REPO", args.repo, output_content) + output_content = Renderer.replace_templated("CHALLENGE_CATEGORY", args.challenge.category, output_content) + output_content = Renderer.replace_templated("CHALLENGE_TYPE", args.challenge.instanced_type, output_content) + output_content = Renderer.replace_templated("CHALLENGE_VERSION", str(args.challenge.get_version()), output_content) + output_content = Renderer.replace_templated("CHALLENGE_ENABLED", str(args.challenge.enabled).lower(), output_content) + output_content = Renderer.replace_templated("HOST", "{{ .Values.kubectf.host }}", output_content) # Insert the current date, for knowing when the challenge was last updated now = datetime.now() current_date = now.strftime("%Y-%m-%d %H:%M:%S") - output_content = self.replace_templated("CURRENT_DATE", current_date, output_content) + output_content = Renderer.replace_templated("CURRENT_DATE", current_date, output_content) configmap_dir =Utils.get_configmap_dir(args.challenge.category, args.challenge.slug) if not os.path.exists(configmap_dir): From 3849beb0e4c5c33acaf94be4c7cb08dbc6ab7c5b Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Wed, 3 Dec 2025 22:50:13 +0100 Subject: [PATCH 18/26] Refactor add_prerequisite method to improve validation logic and ensure prerequisite uniqueness --- src/library/data.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/library/data.py b/src/library/data.py index 26ff9d4..164ccf6 100644 --- a/src/library/data.py +++ b/src/library/data.py @@ -359,16 +359,16 @@ def set_handout_dir(self, handout_dir: str): def add_dockerfile_location(self, locations: List[DockerfileLocation]): self.dockerfile_locations.extend(locations) - def add_prerequisite(self, prerequisite: Optional[str]): + def add_prerequisite(self, prerequisite: Optional[str]): prerequisite = Utils.slugify(prerequisite) - if Utils.validate_length(prerequisite, 1, 50, "prerequisite") == False: - raise ValueError("Prerequisite must be between 1 and 50 characters.") - if prerequisite is None: print("Prerequisite must be provided.") raise ValueError("Prerequisite must be provided.") + if Utils.validate_length(prerequisite, 1, 50, "prerequisite") == False: + raise ValueError("Prerequisite must be between 1 and 50 characters.") + if prerequisite in self.prerequisites: print(f"Prerequisite {prerequisite} already exists.") raise ValueError("Prerequisite already exists.") From 4d8136848b534c90ed293e8a9b5ddd64b6ecbc56 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Wed, 3 Dec 2025 22:51:24 +0100 Subject: [PATCH 19/26] Fix formatting inconsistencies in README.md and update repository references --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 380c63e..998395a 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ PAGE_SCHEMA = "https://raw.githubusercontent.com/ctfpilot/page-schema/refs/heads # Allowed values for schema fields CHALL_TYPES = [ "static", "shared", "instanced" ] -DIFFICULTIES = [ "beginner", "easy", "easy-medium", "medium", "medium-hard","hard", "very-hard", "insane"] +DIFFICULTIES = [ "beginner", "easy", "easy-medium", "medium", "medium-hard", "hard", "very-hard", "insane"] CATEGORIES = [ "web", "forensics", "rev", "crypto", "pwn", "boot2root", "osint", "misc", "blockchain", "mobile", "test" ] INSTANCED_TYPES = [ "none", "web", "tcp" ] # "none" is the default. Defines how users interact with the challenge. @@ -293,7 +293,7 @@ python challenge-toolkit/src/ctf.py template k8s web/sql-injection-101 # Generate ConfigMap with custom expiry time (2 hours) and repo python challenge-toolkit/src/ctf.py template configmap web/sql-injection-101 \ --expires 7200 \ - --repo ctfpilot/ctfpilot/ctf-challenges + --repo ctfpilot/ctf-challenges # Create handout archive python challenge-toolkit/src/ctf.py template handout web/sql-injection-101 @@ -388,7 +388,7 @@ python challenge-toolkit/src/ctf.py page [options] ```sh # Render a custom page -python challenge-toolkit/src/ctf.py page rules --repo ctfpilot/ctfpilot/ctf-challenges +python challenge-toolkit/src/ctf.py page rules --repo ctfpilot/ctf-challenges # Render about page python challenge-toolkit/src/ctf.py page about From 5f720043f9d271f54df386cd371dc37ddeff6ea1 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Wed, 3 Dec 2025 22:58:04 +0100 Subject: [PATCH 20/26] Fix indentation in test_missing_type method for consistency --- src/tests/library/dataTest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/library/dataTest.py b/src/tests/library/dataTest.py index 0e05331..89be844 100644 --- a/src/tests/library/dataTest.py +++ b/src/tests/library/dataTest.py @@ -201,7 +201,7 @@ def test_missing_difficulty(self): ) def test_missing_type(self): - with self.assertRaises(ValueError): + with self.assertRaises(ValueError): Challenge( enabled=True, name="Test Challenge", From a8000027aa2bedb295490034e3128e52cd25ebf6 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Wed, 3 Dec 2025 22:58:10 +0100 Subject: [PATCH 21/26] Add validation for enabled property and update Dockerfile base image and commands --- src/library/data.py | 3 +++ src/library/generator.py | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/library/data.py b/src/library/data.py index 164ccf6..e512f64 100644 --- a/src/library/data.py +++ b/src/library/data.py @@ -175,6 +175,9 @@ def __init__( def set_enabled(self, enabled: bool): + if not isinstance(enabled, bool): + raise ValueError("Enabled must be a boolean") + self.enabled = enabled def set_name(self, name: str): diff --git a/src/library/generator.py b/src/library/generator.py index 58cb6d3..54a8269 100644 --- a/src/library/generator.py +++ b/src/library/generator.py @@ -245,11 +245,14 @@ def dockerfile(self): path = os.path.join(self.dir_src, "Dockerfile") with open(path, "w") as f: f.write(f"# Dockerfile for {self.challenge.category} - {self.challenge.name}\n") - f.write("FROM ubuntu:latest\n") + f.write("FROM ubuntu:22.04\n") f.write("\n") - f.write("RUN apt-get update && apt-get install -y python3\n") + f.write("RUN apt-get update && apt-get upgrade -y && apt-get install -y python3") + f.write("\n") + f.write("RUN useradd -m challengeuser\n") + f.write("\n") + f.write("USER challengeuser\n") f.write("\n") - f.write("CMD [\"/bin/bash\"]\n") print(f"File created: {path}") From d98761823e26d1da01c97f58a1215335a2a1c9b1 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Wed, 3 Dec 2025 22:59:29 +0100 Subject: [PATCH 22/26] Add validation to HandoutRenderer for items within the handout directory --- src/commands/template_renderer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/template_renderer.py b/src/commands/template_renderer.py index b63d775..9c9de13 100644 --- a/src/commands/template_renderer.py +++ b/src/commands/template_renderer.py @@ -325,6 +325,11 @@ def render(self): # Copy files from the handout directory to the temporary directory for item in os.listdir(handout_path): + # Verify the item is within the handout directory + if not os.path.commonpath([os.path.abspath(handout_path), os.path.abspath(os.path.join(handout_path, item))]) == os.path.abspath(handout_path): + print(f"Skipping item {item} as it is outside the handout directory.") + continue + source_item = os.path.join(handout_path, item) dest_item = os.path.join(temp_handout_path, item) From 11834c0eca0942f12cc03992f3d9e628a6b88243 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Wed, 3 Dec 2025 23:06:29 +0100 Subject: [PATCH 23/26] Fix typo in comment for instanced challenge template handling --- src/commands/template_renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/template_renderer.py b/src/commands/template_renderer.py index 9c9de13..35b3ce4 100644 --- a/src/commands/template_renderer.py +++ b/src/commands/template_renderer.py @@ -132,7 +132,7 @@ def render(self, args: Args): base_template_content, challenge_template, challenge_template_indented = self.get_template_content() - # If instanced, it needs to utalize the base template for instanced challenges + # If instanced, it needs to utilize the base template for instanced challenges templateing_base_template = challenge_template if self.challenge.type == "instanced": templateing_base_template = base_template_content From 8bafbc89d94e79d089365bfcfa6ede333633ac76 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Thu, 4 Dec 2025 21:30:48 +0100 Subject: [PATCH 24/26] Enhance handout directory validation in HandoutRenderer to use resolved paths --- src/commands/template_renderer.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/commands/template_renderer.py b/src/commands/template_renderer.py index 35b3ce4..2ae4d41 100644 --- a/src/commands/template_renderer.py +++ b/src/commands/template_renderer.py @@ -3,6 +3,7 @@ import argparse import tempfile import shutil +from pathlib import Path from datetime import datetime @@ -324,16 +325,24 @@ def render(self): os.makedirs(temp_handout_path, exist_ok=True) # Copy files from the handout directory to the temporary directory + handout_base = Path(handout_path).resolve() for item in os.listdir(handout_path): - # Verify the item is within the handout directory - if not os.path.commonpath([os.path.abspath(handout_path), os.path.abspath(os.path.join(handout_path, item))]) == os.path.abspath(handout_path): + # Verify the item is within the handout directory using resolved paths + source_item = handout_base / item + try: + source_item_resolved = source_item.resolve() + # Ensure the resolved path is within the handout directory + source_item_resolved.relative_to(handout_base) + except (ValueError, RuntimeError): print(f"Skipping item {item} as it is outside the handout directory.") continue - source_item = os.path.join(handout_path, item) + source_item = str(source_item_resolved) dest_item = os.path.join(temp_handout_path, item) - if item in ['.gitkeep', '.gitignore']: + # Get filename + item_filename = os.path.basename(item) + if item_filename in ['.gitkeep', '.gitignore']: # Skip .gitkeep and .gitignore files continue From 49ab925e1facc95d3cfc61f50a54b0adfb7ec57f Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Thu, 4 Dec 2025 21:30:55 +0100 Subject: [PATCH 25/26] Refactor ChallengeCreator to conditionally prompt for challenge details and handle slug generation --- src/commands/challenge_creator.py | 63 +++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/src/commands/challenge_creator.py b/src/commands/challenge_creator.py index e90ec1d..0f04166 100644 --- a/src/commands/challenge_creator.py +++ b/src/commands/challenge_creator.py @@ -61,6 +61,8 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid name. Please try again.") + else: + challenge.set_name(args.name) if args.slug is None: while True: @@ -69,6 +71,8 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid slug. Please try again.") + else: + challenge.set_slug(args.slug) if args.author is None: while True: @@ -77,6 +81,8 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid author. Please try again.") + else: + challenge.set_author(args.author) if args.category is None: while True: @@ -85,6 +91,8 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid category. Please try again.") + else: + challenge.set_category(args.category) if args.difficulty is None: while True: @@ -93,6 +101,8 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid difficulty. Please try again.") + else: + challenge.set_difficulty(args.difficulty) prompted_type = None if args.type is None: @@ -103,6 +113,9 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid type. Please try again.") + else: + challenge.set_type(args.type) + prompted_type = args.type if args.flag is None: while True: @@ -111,6 +124,8 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid flag. Please try again.") + else: + challenge.set_flag(args.flag) if args.points is None: while True: @@ -119,6 +134,8 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid points. Please try again.") + else: + challenge.set_points(args.points) if args.min_points is None: while True: @@ -127,6 +144,8 @@ def prompt(self, challenge: Challenge): break except ValueError: print("Invalid minimum points. Please try again.") + else: + challenge.set_min_points(args.min_points) if (args.type == "instanced" or prompted_type == "instanced") and args.instanced_type == "none": while True: @@ -205,10 +224,26 @@ def run(self): print("Error parsing arguments. Please run with --help to see available options.") sys.exit(1) - if args.slug is None: + if args.name and args.slug is None: args.slug = Utils.slugify(args.name) if args.name else "challenge" - challenge = Challenge( + challenge = None + if args.no_prompts == False: + challenge = Challenge(name="demo", slug="demo", author="demo", category="misc", difficulty="easy", type="static", flag="flag{demo_flag}") + arguments.prompt(challenge) + + print("\nInformation filled out.") + + print("\nInformation for the challenge:") + print(challenge) + + print("\nIs the information correct?") + if (input("Y/n: ") or "y").lower() != "y": + print("Exiting...") + sys.exit(1) + + else: + challenge = Challenge( name = args.name, slug = args.slug, author = args.author, @@ -222,26 +257,14 @@ def run(self): description_location = args.description_location, handout_dir = args.handout_location ) - if args.no_prompts and args.type != "static": - try: - if args.dockerfile_location: - challenge.add_dockerfile_location([ DockerfileLocation(args.dockerfile_location, args.dockerfile_context, args.dockerfile_identifier) ]) - except ValueError: - sys.exit(1) - if args.no_prompts == False: - arguments.prompt(challenge) + if args.type != "static": + try: + if args.dockerfile_location: + challenge.add_dockerfile_location([ DockerfileLocation(args.dockerfile_location, args.dockerfile_context, args.dockerfile_identifier) ]) + except ValueError: + sys.exit(1) - print("\nInformation filled out.") - - print("\nInformation for the challenge:") - print(challenge) - - print("\nIs the information correct?") - if (input("Y/n: ") or "y").lower() != "y": - print("Exiting...") - sys.exit(1) - generator = Generator(challenge) generator.generate() From 717009404df18951715ad3551ea67399f49f3de7 Mon Sep 17 00:00:00 2001 From: The0Mikkel Date: Thu, 4 Dec 2025 21:38:28 +0100 Subject: [PATCH 26/26] feat: add initial Challenge toolkit --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 998395a..5c8d22d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,16 @@ A comprehensive CLI toolkit for CTF challenge development, deployment, and manag The Challenge Toolkit streamlines the entire CTF challenge lifecycle, from bootstrapping new challenges with proper directory structures to building Docker images and generating Kubernetes deployment manifests. Built to work seamlessly with [CTF Pilot's infrastructure](https://github.com/ctfpilot), it enforces standardized schemas and automates repetitive tasks, letting you focus on creating great challenges instead of managing boilerplate. +## Supported CTF Pilot versions + +| CTF Pilot Component | Supported Version | +| ---------------------------------------------------------------------------- | ----------------- | +| [CTF Pilot's CTF Platform (CTFp)](https://github.com/ctfpilot/ctfp) | v1.0 | +| [CTF Pilot's Challenge Schema](https://github.com/ctfpilot/challenge-schema) | v1.0 | +| [CTF Pilot's Page Schema](https://github.com/ctfpilot/page-schema) | v1.0 | +| [CTF Pilot's CTFd Manager](https://github.com/ctfpilot/ctfd-manager) | v1.0 | +| [kube-ctf](https://github.com/ctfpilot/kube-ctf) | v1.0 | + ## How to run > [!NOTE]