diff --git a/.github/actions/discover-challenges/action.yml b/.github/actions/discover-challenges/action.yml new file mode 100644 index 0000000..3419bb4 --- /dev/null +++ b/.github/actions/discover-challenges/action.yml @@ -0,0 +1,24 @@ +name: 'Discover CTF challenges in a directory' +description: 'Finds CTF challenge directories that contain a challenge.yml file' + +inputs: + base-dir: + description: 'Directory to discover challenges from' + required: true + default: '.' + +outputs: + dirs: + description: "Challenge directories" + value: ${{ steps.find-challenge-dirs.outputs.dirs }} + +runs: + using: "composite" + steps: + - name: Find directories containing challenge.yml + id: find-challenge-dirs + run: | + dirs=$(find ${{ inputs.base_dir }} -name 'challenge.yml' -printf '%h\n' | sort -u) + echo dirs="${dirs//$'\n'/ }" >> "$GITHUB_OUTPUT" + shell: bash + \ No newline at end of file diff --git a/.github/actions/generate-readme/Dockerfile b/.github/actions/generate-readme/Dockerfile new file mode 100644 index 0000000..c46a2bf --- /dev/null +++ b/.github/actions/generate-readme/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.8-slim-buster + +COPY entrypoint.py /entrypoint.py +COPY README.jinja /README.jinja +COPY challenge_README.jinja /challenge_README.jinja + +# Python dependencies +RUN pip install pyyaml jinja2 + +# File to execute when the docker container starts up (`entrypoint.sh`) +ENTRYPOINT ["python", "/entrypoint.py"] \ No newline at end of file diff --git a/.github/actions/generate-readme/README.jinja b/.github/actions/generate-readme/README.jinja new file mode 100644 index 0000000..fa6902d --- /dev/null +++ b/.github/actions/generate-readme/README.jinja @@ -0,0 +1,24 @@ +![Grant thornton Beginner Quest](_assets/gtbq.png) +# Grant Thornton Beginner Quest 2024 + +**Dates:** 05/07/2024 - 14/07/2024 + +## Repository Structure + +This is the official repository with the challenges published in Grant Thornton Beginner Quest (GTBQ) CTF 2024. Each challenge has a public, solution and setup folder (if applicable) and is accompanied with a short description. The setup folder contains all the files required to build and host the challenge and usually contains the flag and a proof of concept solution as well. The public folder contains the files that are released to the participant during the competition. + +## Dependencies + +Although some of the challenges may run as is, it is recommended that you have docker and docker-compose installed and use the provided scripts to run the challenges to ensure isolation and therefore proper environment setup. + +## Challenges + +{% for category, challenges in challenge_categories.items() %} +### {{ category }} + +| Name | Author | +| ---- | ------ | +{% for challenge in challenges %}| [{{ challenge.name }}]({{ challenge.dir }}) | {{ challenge.author }} | +{% endfor %} + +{% endfor %} \ No newline at end of file diff --git a/.github/actions/generate-readme/action.yml b/.github/actions/generate-readme/action.yml new file mode 100644 index 0000000..41e450d --- /dev/null +++ b/.github/actions/generate-readme/action.yml @@ -0,0 +1,13 @@ +name: 'Generate Challenge README' +description: 'Generates a README file with a table of challenges from provided directories' + +inputs: + directories: + description: 'Comma separated list of directories that contain a challenge.yml file' + required: true + +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.directories }} diff --git a/.github/actions/generate-readme/challenge_README.jinja b/.github/actions/generate-readme/challenge_README.jinja new file mode 100644 index 0000000..a2b0d3d --- /dev/null +++ b/.github/actions/generate-readme/challenge_README.jinja @@ -0,0 +1,26 @@ +# {{ challenge.name }} +{% if challenge.type == "dynamic_docker" %} +[![Try in PWD](https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png)](https://labs.play-with-docker.com/?stack={{ challenge.docker_compose_url }}) +{% endif %} + +**Category**: {{ challenge.category }} + +**Author**: {{ challenge.author }} + +## Description + +{{ challenge.description }} + +{% if challenge.type == "dynamic_docker" %} +## Run locally + +Launch challenge: +``` +curl -sSL {{ challenge.docker_compose_url }} | docker compose -f - up -d +``` + +Shutdown challenge: +``` +curl -sSL {{ challenge.docker_compose_url }} | docker compose -f - down +``` +{% endif %} \ No newline at end of file diff --git a/.github/actions/generate-readme/entrypoint.py b/.github/actions/generate-readme/entrypoint.py new file mode 100755 index 0000000..d117157 --- /dev/null +++ b/.github/actions/generate-readme/entrypoint.py @@ -0,0 +1,54 @@ +import os +import yaml +import sys +from jinja2 import Environment, FileSystemLoader +from urllib.parse import urljoin + + +class IgnoreSpecificConstructorLoader(yaml.SafeLoader): + def ignore_constructor(self, node): + return None + + +IgnoreSpecificConstructorLoader.add_constructor( + "!filecontents", IgnoreSpecificConstructorLoader.ignore_constructor +) + + +def parse_challenge(directory): + path = os.path.join(directory, "challenge.yml") + print(path) + with open(path, "r") as file: + return yaml.load(file, Loader=IgnoreSpecificConstructorLoader) + + +def main(): + directories = sys.argv[1].split(" ") + challenge_categories = {} + + file_loader = FileSystemLoader("/") + env = Environment(loader=file_loader) + challenge_readme_tmpl = env.get_template("challenge_README.jinja") + + for directory in directories: + challenge = parse_challenge(directory) + category = challenge["category"] + challenge["dir"] = directory + challenge["docker_compose_url"] = urljoin("https://raw.githubusercontent.com/cybermouflons/gt-beginner-quest-2024/master/", f"{directory}/docker-compose.yml") + if category not in challenge_categories: + challenge_categories[category] = [] + challenge_categories[category].append(challenge) + + chall_readme = challenge_readme_tmpl.render(challenge=challenge) + with open(os.path.join(directory, "README.md"), "w") as f: + f.write(chall_readme) + + readme_tmpl = env.get_template("README.jinja") + output = readme_tmpl.render(challenge_categories=challenge_categories) + + with open("README.md", "w") as file: + file.write(output) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..859bc65 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,127 @@ +name: Build ans Push Challenges + +on: [workflow_call] # allow this workflow to be called from other workflows + +env: + REGISTRY: ghcr.io + +jobs: + run: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + lfs: 'false' + + - name: Challenge discovery + id: challenge-discovery + uses: ./.github/actions/discover-challenges + with: + base-dir: '.' + + - name: Docker challenge discovery + id: docker-chall-discovery + run: | + IFS=' ' read -r -a chall_dirs <<< "${{ steps.challenge-discovery.outputs.dirs }}" + for dir in "${chall_dirs[@]}"; do + if [[ -f "${dir}/docker-compose.yml" ]]; then + dirs+=("${dir}") + fi + done + echo "dirs=${dirs[*]}" >> "$GITHUB_OUTPUT" + + - name: Docker challenges list + run: echo "${{ steps.docker-chall-discovery.outputs.dirs }}" + + - uses: actions/cache/restore@v3 + id: challenges-hashes-cache + with: + path: .cache/last_hashes + key: last-hashes + + - name: Create folder challenge hashes + id: challenge-hashes + run: | + if [ -d .cache ]; then + rm -f .cache/hashes + fi + mkdir -p .cache + touch .cache/hashes + IFS=' ' read -r -a challenge_dirs <<< "${{ steps.docker-chall-discovery.outputs.dirs }}" + for dir in "${challenge_dirs[@]}"; do + echo "$(find $dir -type f -print0 | sort -z | xargs -0 sha1sum | sha1sum | cut -d " " -f 1) $dir" >> .cache/hashes + done + sort .cache/hashes -o .cache/hashes + + - name: Find modified challenges + id: modified-chalenges + run: | + COMMIT_MESSAGE=$(git log --format=%B -n 1) + if [[ "$COMMIT_MESSAGE" == *"[no-cache]"* ]]; then + rm -f .cache/last_hashes + fi + touch .cache/last_hashes + changes=$(diff .cache/hashes .cache/last_hashes) || true + if [[ -n "$changes" ]]; then + changed_dirs=$(echo "$changes" | grep '<' | cut -d ' ' -f 3-) + echo "$changed_dirs" + fi + mv .cache/hashes .cache/last_hashes + echo dirs="${changed_dirs//$'\n'/ }" >> "$GITHUB_OUTPUT" + + - name: Changed challenges list + run: echo "${{ steps.modified-chalenges.outputs.dirs }}" + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Build challenges + run: | + IFS=' ' read -r -a challenge_dirs <<< "${{ steps.modified-chalenges.outputs.dirs }}" + for challenge_dir in "${challenge_dirs[@]}"; do + docker compose -f $challenge_dir/docker-compose.yml build + done + + - name: Push challenges + run: | + IFS=' ' read -r -a challenge_dirs <<< "${{ steps.modified-chalenges.outputs.dirs }}" + for challenge_dir in "${challenge_dirs[@]}"; do + docker compose -f $challenge_dir/docker-compose.yml push + done + + - name: Clear cache + uses: actions/github-script@v6 + with: + script: | + console.log("About to clear") + const cachesToDelete = ["last-hashes"]; + const caches = await github.rest.actions.getActionsCacheList({ + owner: context.repo.owner, + repo: context.repo.repo, + }) + for (const cache of caches.data.actions_caches) { + if (cachesToDelete.includes(cache.key)) { + console.log(cache) + github.rest.actions.deleteActionsCacheById({ + owner: context.repo.owner, + repo: context.repo.repo, + cache_id: cache.id, + }) + } + } + console.log("Clear completed") + + - name: Save folder challenge hashes + id: challenge-hashes-save + uses: actions/cache/save@v3 + with: + path: .cache/last_hashes + key: last-hashes diff --git a/.github/workflows/ctfd.yml b/.github/workflows/ctfd.yml new file mode 100644 index 0000000..41a7fae --- /dev/null +++ b/.github/workflows/ctfd.yml @@ -0,0 +1,104 @@ +name: Sync CTFd + +on: [workflow_call] # allow this workflow to be called from other workflows + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Create .ctf/config file + run: | + mkdir -p .ctf + echo "${{ secrets.CTF_CLI_CONFIG }}" > .ctf/config + + - name: Install ctfcli + run: | + python -m pip install --upgrade pip + pip install git+https://github.com/apogiatzis/ctfcli + + - name: Challenge discovery + id: challenge-discovery + uses: ./.github/actions/discover-challenges + with: + base-dir: '.' + + - uses: actions/cache/restore@v3 + id: challenges-hashes-cache + with: + path: .cache/last_hashes + key: ctfd-pipeline-last-hashes + + - name: Create folder challenge hashes + id: challenge-hashes + run: | + if [ -d .cache ]; then + rm -f .cache/hashes + fi + mkdir -p .cache + touch .cache/hashes + IFS=' ' read -r -a challenge_dirs <<< "${{ steps.challenge-discovery.outputs.dirs }}" + for dir in "${challenge_dirs[@]}"; do + echo "$(find $dir -type f -print0 | sort -z | xargs -0 sha1sum | sha1sum | cut -d " " -f 1) $dir" >> .cache/hashes + done + sort .cache/hashes -o .cache/hashes + + - name: Find modified challenges + id: modified-chalenges + run: | + COMMIT_MESSAGE=$(git log --format=%B -n 1) + if [[ "$COMMIT_MESSAGE" == *"[no-cache]"* ]]; then + rm -f .cache/last_hashes + fi + touch .cache/last_hashes + changes=$(diff .cache/hashes .cache/last_hashes) || true + if [[ -n "$changes" ]]; then + changed_dirs=$(echo "$changes" | grep '<' | cut -d ' ' -f 3-) + echo "$changed_dirs" + fi + mv .cache/hashes .cache/last_hashes + echo dirs="${changed_dirs//$'\n'/ }" >> "$GITHUB_OUTPUT" + + - name: Changed challenges list + run: echo "${{ steps.modified-chalenges.outputs.dirs }}" + + - name: Challenge Sync + id: challenge-sync + run: | + IFS=' ' read -r -a chall_dirs <<< "${{ steps.modified-chalenges.outputs.dirs }}" + for dir in "${chall_dirs[@]}"; do + ctf challenge install ${dir} + ctf challenge sync ${dir} + done + echo "dirs=${dirs[*]}" >> "$GITHUB_OUTPUT" + + - name: Clear cache + uses: actions/github-script@v6 + with: + script: | + console.log("About to clear") + const cachesToDelete = ["ctfd-pipeline-last-hashes"]; + const caches = await github.rest.actions.getActionsCacheList({ + owner: context.repo.owner, + repo: context.repo.repo, + }) + for (const cache of caches.data.actions_caches) { + if (cachesToDelete.includes(cache.key)) { + console.log(cache) + github.rest.actions.deleteActionsCacheById({ + owner: context.repo.owner, + repo: context.repo.repo, + cache_id: cache.id, + }) + } + } + console.log("Clear completed") + + - name: Save folder challenge hashes + id: challenge-hashes-save + uses: actions/cache/save@v3 + with: + path: .cache/last_hashes + key: ctfd-pipeline-last-hashes diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0720f97 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: Lint Challenges + +on: [workflow_call] # allow this workflow to be called from other workflows + +jobs: + run: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Install ctfcli + run: | + python -m pip install --upgrade pip + pip install git+https://github.com/apogiatzis/ctfcli + + - name: Challenge discovery + id: challenge-discovery + uses: ./.github/actions/discover-challenges + with: + base-dir: '.' + + - name: Challenge list + run: echo "${{ steps.challenge-discovery.outputs.dirs }}" + + - name: Lint challenges + run: | + IFS=' ' read -r -a challenge_dirs <<< "${{ steps.challenge-discovery.outputs.dirs }}" + for challenge_dir in "${challenge_dirs[@]}"; do + ctf challenge lint $challenge_dir + done \ No newline at end of file diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..0518486 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,28 @@ +name: Challenge Pipeline + + +on: + push: + branches: ['master'] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + uses: ./.github/workflows/lint.yml + + build: + uses: ./.github/workflows/build.yml + needs: lint + + ctfd: + uses: ./.github/workflows/ctfd.yml + needs: build + secrets: inherit + + readme: + uses: ./.github/workflows/readme.yml + needs: ctfd + secrets: inherit diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml new file mode 100644 index 0000000..5e7bc5b --- /dev/null +++ b/.github/workflows/readme.yml @@ -0,0 +1,33 @@ +name: Generate README files + +on: [workflow_call] # allow this workflow to be called from other workflows + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Challenge discovery + id: challenge-discovery + uses: ./.github/actions/discover-challenges + with: + base-dir: '.' + + - name: Generate README.md + id: generate-readme + uses: ./.github/actions/generate-readme + with: + directories: "${{ steps.challenge-discovery.outputs.dirs }}" + + - name: Sense check + run: cat README.md + + - name: Commit and push if it's not a pull request + if: github.event_name == 'push' && github.repository == github.event.repository.full_name + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add "./*README.md" + git diff --quiet && git diff --staged --quiet || (echo 'Committing changes...' && git commit -m "[GitHub Action] Update challenges in README.md" && git push) \ No newline at end of file