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..5c8d22d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,619 @@ # 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. + +## 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] +> 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 +python challenge-toolkit/src/ctf.py [arguments] [options] +``` + +> [!IMPORTANT] +> 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 accordance 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: + +| 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: + +- 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 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: + +```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 +``` + +### Typical usage + +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. +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 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. + + 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. + The created archive is stored in the `k8s/files/` directory as `_.zip`. It will ignore the files `.gitkeep` and `.gitignore`. + +**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/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 | + +**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/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 + +> [!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 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 +├── pages/ +│ └── page-1/ +├── template/ +├── challenge-toolkit/ +└── +``` + +*`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`. + +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: + +```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 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). + +#### 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 Docker image naming convention is described in the [`pipeline` command section](#pipeline---build-and-tag-docker-images) above. + +### Template structure + +> [!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. + +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. + +The following templates are required: + +- 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 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. + +### 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 +. +├── 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 diff --git a/src/commands/challenge_creator.py b/src/commands/challenge_creator.py new file mode 100644 index 0000000..0f04166 --- /dev/null +++ b/src/commands/challenge_creator.py @@ -0,0 +1,274 @@ +''' +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: + print("Invalid name. Please try again.") + else: + challenge.set_name(args.name) + + 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") + break + except ValueError: + print("Invalid slug. Please try again.") + else: + challenge.set_slug(args.slug) + + if args.author is None: + while True: + try: + challenge.set_author(input("Author of the challenge: ")) + break + except ValueError: + print("Invalid author. Please try again.") + else: + challenge.set_author(args.author) + + if args.category is None: + while True: + try: + challenge.set_category(input(f"Category of the challenge ({', '.join(CATEGORIES)}): ").lower()) + break + except ValueError: + print("Invalid category. Please try again.") + else: + challenge.set_category(args.category) + + if args.difficulty is None: + while True: + try: + challenge.set_difficulty(input(f"Difficulty of the challenge ({', '.join(DIFFICULTIES)}): ").lower()) + break + except ValueError: + print("Invalid difficulty. Please try again.") + else: + challenge.set_difficulty(args.difficulty) + + prompted_type = None + if args.type is None: + while True: + try: + prompted_type = input(f"Type of the challenge ({', '.join(CHALL_TYPES)}): ").lower() + challenge.set_type(prompted_type) + 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: + try: + challenge.set_flag(input(f"Flag for the challenge ({FLAG_FORMAT}): ")) + break + except ValueError: + print("Invalid flag. Please try again.") + else: + challenge.set_flag(args.flag) + + if args.points is None: + while True: + try: + challenge.set_points(int(input("Points for the challenge (1000): ") or 1000)) + break + except ValueError: + print("Invalid points. Please try again.") + else: + challenge.set_points(args.points) + + if args.min_points is None: + while True: + try: + challenge.set_min_points(int(input("Minimum points for the challenge (100): ") or 100 )) + 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: + try: + challenge.set_instanced_type(input(f"Type of instanced challenge ({', '.join(INSTANCED_TYPES)}): ").lower()) + break + except ValueError: + print("Invalid instanced type. Please try again.") + 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: + print("Invalid description location. Please try again.") + else: + challenge.set_description_location(args.description_location) + + 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: + 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: + print("Invalid Dockerfile location. Please try again.") + + 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() + + 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.name and args.slug is None: + args.slug = Utils.slugify(args.name) if args.name else "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, + 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.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) + + 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..f81291f --- /dev/null +++ b/src/commands/page.py @@ -0,0 +1,189 @@ +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 +from library.config import PAGE_SCHEMA + +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") + 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", "")) + + 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", "") + + 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) + +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(PAGE_SCHEMA) + + # 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() + + 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..196a077 --- /dev/null +++ b/src/commands/pipeline.py @@ -0,0 +1,154 @@ +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: + 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, build_command) + + 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, push_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() + + 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..2793b80 --- /dev/null +++ b/src/commands/slugify.py @@ -0,0 +1,61 @@ +import sys +import argparse + +from library.utils import Utils + +class Args: + args = None + slug: str = "" + 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") + + 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) or "" + +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() + + 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..2ae4d41 --- /dev/null +++ b/src/commands/template_renderer.py @@ -0,0 +1,405 @@ +import os +import sys +import argparse +import tempfile +import shutil +from pathlib import Path + +from datetime import datetime + +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 + 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 pages in the format 'owner/repo'", default=os.getenv("GITHUB_REPOSITORY", "")) + + 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", "") + + 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) + +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 Renderer: + @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 + + +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") + 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 utilize 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 = 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 = 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): + 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 get_template_content(self): + 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()]) + + 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 = 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 = 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): + 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 + handout_base = Path(handout_path).resolve() + for item in os.listdir(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 = str(source_item_resolved) + dest_item = os.path.join(temp_handout_path, item) + + # Get filename + item_filename = os.path.basename(item) + if item_filename 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() + + 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/library/config.py b/src/library/config.py new file mode 100644 index 0000000..b3094be --- /dev/null +++ b/src/library/config.py @@ -0,0 +1,40 @@ +from pathlib import Path + +# 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" +} diff --git a/src/library/data.py b/src/library/data.py new file mode 100644 index 0000000..e512f64 --- /dev/null +++ b/src/library/data.py @@ -0,0 +1,712 @@ +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 is 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 is not None: + self.set_enabled(enabled) + else: self.set_enabled(DEFAULT['enabled']) + if name is not None: + self.set_name(name) + else: self.set_name(DEFAULT['name']) + if slug is not None: + self.set_slug(slug) + else: self.set_slug(DEFAULT['slug']) + if author is not None: + self.set_author(author) + else: self.set_author(DEFAULT['author']) + if category is not None: + self.set_category(category) + else: self.set_category(DEFAULT['category']) + if difficulty is not None: + self.set_difficulty(difficulty) + else: self.set_difficulty(DEFAULT['difficulty']) + if type is not None: + self.set_type(type) + else: self.set_type(DEFAULT['type']) + if instanced_type is not None: + self.set_instanced_type(instanced_type) + else: self.set_instanced_type(DEFAULT['instanced_type']) + if tags is not None: + self.set_tags(tags) + else: self.set_tags(DEFAULT['tags']) + if instanced_name is not None: + self.instanced_name = instanced_name + else: self.instanced_name = DEFAULT['instanced_name'] + if instanced_subdomains is not None: + self.set_instanced_subdomains(instanced_subdomains) + else: self.instanced_subdomains = DEFAULT['instanced_subdomains'] + if connection is not None: + self.set_connection(connection) + else: self.connection = DEFAULT['connection'] + if flag is not None: + self.set_flag(flag) + else: self.set_flag(DEFAULT['flag']) + if points is not None: + self.set_points(points) + else: self.set_points(DEFAULT['points']) + if decay is not None: + self.set_decay(decay) + else: self.set_decay(DEFAULT['decay']) + if min_points is not None: + self.set_min_points(min_points) + else: self.set_min_points(DEFAULT['min_points']) + if description_location is not None: + self.set_description_location(description_location) + else: self.set_description_location(DEFAULT['description_location']) + if handout_dir is not 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): + if not isinstance(enabled, bool): + raise ValueError("Enabled must be a boolean") + + 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 is 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 is 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 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.") + + 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..54a8269 --- /dev/null +++ b/src/library/generator.py @@ -0,0 +1,316 @@ +import os +import sys + +from typing import Literal, Optional +from pathlib import Path + +from .data import Challenge, Page +from .utils import Utils +from .config import CHALLENGE_SCHEMA + +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(CHALLENGE_SCHEMA) + if format == "json": + content = self.challenge.str_json(CHALLENGE_SCHEMA) + + 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 contain 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:22.04\n") + f.write("\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") + + 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..f9f121e --- /dev/null +++ b/src/library/utils.py @@ -0,0 +1,88 @@ +from pathlib import Path +from slugify import slugify +import yaml +import json + +from .config import CHALLENGE_REPO_ROOT + +class Utils: + @staticmethod + def get_repo_dir() -> Path: + return CHALLENGE_REPO_ROOT + + @staticmethod + def get_challenges_dir() -> Path: + return Utils.get_repo_dir().joinpath('challenges') + + @staticmethod + def get_pages_dir() -> Path: + return Utils.get_repo_dir().joinpath('pages') + + @staticmethod + 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) -> Path: + 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) -> Path: + return Utils.get_page_dir(page).joinpath('k8s') + + @staticmethod + 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) -> Path: + return Utils.get_k8s_dir(category, slug).joinpath('config') + + @staticmethod + def get_template_dir() -> Path: + return Utils.get_repo_dir().joinpath('template') + + @staticmethod + def slugify(text): + 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 is 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/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..93aef1f --- /dev/null +++ b/src/test.py @@ -0,0 +1,10 @@ +import unittest +import io +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..89be844 --- /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 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..fd99168 --- /dev/null +++ b/template/instanced-tcp-k8s.yml @@ -0,0 +1,115 @@ +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: "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 }}" + 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" + 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 }}" + 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 }}" + 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..8caf34d --- /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 }}" + 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: + 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: "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 }}" + 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: 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 }}" + 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 }}" + 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..ed4b958 --- /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%%