diff --git a/.deploy/cleanup.yaml b/.deploy/cleanup.yaml
new file mode 100644
index 0000000..95a7a28
--- /dev/null
+++ b/.deploy/cleanup.yaml
@@ -0,0 +1,135 @@
+- name: Pre-deployment system cleanup
+ hosts: all
+ order: shuffle
+ gather_facts: false
+ any_errors_fatal: true
+
+ tasks:
+ - name: Make network configuration static
+ ansible.builtin.shell: |
+ [ ! -e /run/systemd/resolve/resolv.conf ] && exit 0
+ rm -f /etc/resolv.conf || true
+ cat /run/systemd/resolve/resolv.conf > /etc/resolv.conf
+ when: 'nsec_production | default(False)'
+ changed_when: true
+
+ - name: Mask most systemd units
+ ansible.builtin.shell: |
+ for i in \
+ apt-daily-upgrade.service \
+ apt-daily-upgrade.timer \
+ apt-daily.service \
+ apt-daily.timer \
+ console-getty.service \
+ console-setup.service \
+ dmesg.service \
+ dpkg-db-backup.service \
+ dpkg-db-backup.timer \
+ e2scrub_all.service \
+ e2scrub_all.timer \
+ e2scrub_reap.service \
+ emergency.service \
+ fstrim.service \
+ fstrim.timer \
+ getty-static.service \
+ getty@tty1.service \
+ initrd-cleanup.service \
+ initrd-parse-etc.service \
+ initrd-switch-root.service \
+ initrd-udevadm-cleanup-db.service \
+ keyboard-setup.service \
+ kmod-static-nodes.service \
+ ldconfig.service \
+ logrotate.service \
+ logrotate.timer \
+ modprobe@configfs.service \
+ modprobe@dm_mod.service \
+ modprobe@drm.service \
+ modprobe@fuse.service \
+ modprobe@loop.service \
+ motd-news.service \
+ motd-news.timer \
+ netplan-ovs-cleanup.service \
+ rescue.service \
+ rsyslog.service \
+ setvtrgb.service \
+ syslog.socket \
+ systemd-ask-password-console.service \
+ systemd-ask-password-wall.service \
+ systemd-battery-check.service \
+ systemd-bsod.service \
+ systemd-confext.service \
+ systemd-fsck-root.service \
+ systemd-fsckd.service \
+ systemd-fsckd.socket \
+ systemd-hibernate-resume.service \
+ systemd-initctl.service \
+ systemd-initctl.socket \
+ systemd-journal-catalog-update.service \
+ systemd-journal-flush.service \
+ systemd-journald-dev-log.socket \
+ systemd-journald.service \
+ systemd-journald.socket \
+ systemd-pcrextend.socket \
+ systemd-pcrlock-file-system.service \
+ systemd-pcrlock-firmware-code.service \
+ systemd-pcrlock-firmware-config.service \
+ systemd-pcrlock-machine-id.service \
+ systemd-pcrlock-make-policy.service \
+ systemd-pcrlock-secureboot-authority.service \
+ systemd-pcrlock-secureboot-policy.service \
+ systemd-pcrmachine.service \
+ systemd-pcrphase-initrd.service \
+ systemd-pcrphase-sysinit.service \
+ systemd-pcrphase.service \
+ systemd-random-seed.service \
+ systemd-repart.service \
+ systemd-soft-reboot.service \
+ systemd-sysctl.service \
+ systemd-sysext.service \
+ systemd-sysext.socket \
+ systemd-sysupdate-reboot.service \
+ systemd-sysupdate-reboot.timer \
+ systemd-sysupdate.service \
+ systemd-sysupdate.timer \
+ systemd-sysusers.service \
+ systemd-timesyncd.service \
+ systemd-tpm2-setup-early.service \
+ systemd-tpm2-setup.service \
+ systemd-update-done.service \
+ systemd-update-utmp-runlevel.service \
+ systemd-update-utmp.service \
+ ua-reboot-cmds.service \
+ ua-timer.service \
+ ua-timer.timer \
+ ubuntu-advantage.service; do
+ ln -s /dev/null /etc/systemd/system/${i} || true
+ done
+ changed_when: true
+
+ - name: Mask network systemd units
+ ansible.builtin.shell: |
+ for i in \
+ networkd-dispatcher.service \
+ systemd-network-generator.service \
+ systemd-networkd-wait-online.service \
+ systemd-networkd.service \
+ systemd-networkd.socket \
+ systemd-resolved.service \
+ systemd-udev-settle.service \
+ systemd-udev-trigger.service \
+ systemd-udevd-control.socket \
+ systemd-udevd-kernel.socket \
+ systemd-udevd.service; do
+ ln -s /dev/null /etc/systemd/system/${i} || true
+ done
+ when: 'nsec_production | default(False)'
+ changed_when: true
+
+ - name: Remove all cron jobs
+ ansible.builtin.shell: |
+ rm -f /etc/cron.*/* || true
+ changed_when: true
+
+ - name: Reboot the instance
+ ansible.builtin.reboot:
diff --git a/.deploy/common.yaml b/.deploy/common.yaml
new file mode 100644
index 0000000..34ca777
--- /dev/null
+++ b/.deploy/common.yaml
@@ -0,0 +1,14 @@
+- name: Pre-deployment Common
+ hosts: all
+ order: shuffle
+ gather_facts: false
+ any_errors_fatal: true
+
+ tasks:
+ - name: Distro update and Python3 install
+ ansible.builtin.raw: |
+ apt update && apt upgrade -y && apt install -y python3
+ changed_when: true
+
+- name: Importing cleanup.yaml Playbook
+ ansible.builtin.import_playbook: cleanup.yaml
diff --git a/.deploy/common/dns.tf b/.deploy/common/dns.tf
new file mode 100644
index 0000000..6ae54c7
--- /dev/null
+++ b/.deploy/common/dns.tf
@@ -0,0 +1,6 @@
+resource "incus_network_zone" "this" {
+ remote = var.incus_remote
+
+ name = "ctf"
+ description = "DNS zone for the internal .ctf TLD"
+}
diff --git a/.deploy/common/variables.tf b/.deploy/common/variables.tf
new file mode 100644
index 0000000..920a2bf
--- /dev/null
+++ b/.deploy/common/variables.tf
@@ -0,0 +1,13 @@
+variable "incus_remote" {
+ default = "local"
+ type = string
+}
+
+variable "deploy" {
+ default = "dev"
+ type = string
+}
+
+locals {
+ track = yamldecode(file("${path.module}/../track.yaml"))
+}
diff --git a/.deploy/common/versions.tf b/.deploy/common/versions.tf
new file mode 100644
index 0000000..e2a285a
--- /dev/null
+++ b/.deploy/common/versions.tf
@@ -0,0 +1,9 @@
+terraform {
+ required_version = ">=1.5.7"
+ required_providers {
+ incus = {
+ source = "lxc/incus"
+ version = ">=0.1.3"
+ }
+ }
+}
diff --git a/.deploy/track.yaml b/.deploy/track.yaml
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/.deploy/track.yaml
@@ -0,0 +1 @@
+{}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..037fc9c
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,163 @@
+name: Full deployment test
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ name: Full deployment test
+ if: github.repository == 'nsec/ctf-script'
+ timeout-minutes: 45
+ strategy:
+ fail-fast: false
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Git LFS Pull for deployment
+ run: |
+ echo "Pulling all Git LFS"
+ git lfs pull -I **/challenges/**/ansible/**/*
+
+ echo "Pulled files:"
+ { git lfs ls-files | grep -E '[a-f0-9]{10}\s\*'; } || true
+
+ - name: Remove docker
+ run: |
+ sudo apt-get autopurge -y moby-containerd docker uidmap
+ sudo ip link delete docker0
+ sudo nft flush ruleset
+
+ - name: Install dependencies
+ run: |
+ sudo apt-get install --no-install-recommends --yes zfsutils-linux
+
+ - name: Setup squid
+ run: |
+ sudo apt-get install --no-install-recommends --yes squid
+
+ (
+ cat << EOF
+ # No logging
+ cache_access_log /dev/null
+ cache_store_log none
+ cache_log /dev/null
+
+ # Caching
+ maximum_object_size 200 MB
+ cache_mem 1024 MB
+
+ # Port and mode configuration
+ acl local_subnet src 9000::/16
+ http_access allow local_subnet
+ http_access deny all
+ http_port [2602:fc62:ef:11::2]:3128
+
+ # Hide our traces
+ forwarded_for transparent
+ via off
+ reply_header_access X-Cache deny all
+ reply_header_access X-Cache-Lookup deny all
+
+ EOF
+ ) | sudo tee /etc/squid/conf.d/nsec.conf
+
+ echo "2602:fc62:ef:11::2 proxy.ctf-int.internal.nsec.io" | sudo tee /etc/hosts
+ sudo systemctl restart squid --no-block
+ sudo ip -6 a add dev lo 2602:fc62:ef:11::2/128
+
+ - name: Setup Incus
+ run: |
+ curl https://pkgs.zabbly.com/get/incus-stable | sudo sh
+ sudo chmod 666 /var/lib/incus/unix.socket
+
+ incus network create incusbr0
+ incus profile device add default eth0 nic network=incusbr0 name=eth0
+
+ incus storage create default zfs size=100GiB
+ incus profile device add default root disk pool=default path=/
+
+ sudo zfs set sync=disabled default
+
+ sudo ip6tables -I FORWARD -j REJECT
+
+ - name: Setup Ansible
+ run: |
+ pipx install --force --include-deps ansible
+ pipx inject ansible passlib
+
+ - name: Setup OpenTofu
+ run: |
+ curl -sL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
+ chmod +x install-opentofu.sh
+ ./install-opentofu.sh --install-method deb
+ rm -f install-opentofu.sh
+
+ - name: Install python dependencies
+ run: |
+ pip install -e .
+
+ - name: Validate CTF structure
+ run: |
+ ctf validate
+
+ - name: Deployment check
+ run: |
+ ctf check
+
+ - name: File generation
+ run: |
+ ctf generate
+
+ - name: Test deployment looping through tracks
+ run: |
+ IFS=" " read -r -a tracks <<< "$(python3 -c 'from ctf.utils import get_all_available_tracks,validate_track_can_be_deployed;print(str([t for t in get_all_available_tracks() if validate_track_can_be_deployed(t)]).strip("[]\x27").replace("\x27, \x27"," "))')"
+
+ [ "${#tracks[@]}" -eq 0 ] && exit 1
+
+ for track in "${tracks[@]}"
+ do
+ ctf deploy --production --tracks "$track"
+ incus --project="$track" info "$track"
+ done
+
+ ctf destroy --force
+
+ - name: Test full deployment
+ run: |
+ ctf deploy --production
+ [ "$(incus list --all-projects -cn -fcsv | wc -l)" -eq 2 ] || exit 1
+ ctf destroy --force
+
+ - name: Test redeployment of Mock Track Apache PHP
+ run: |
+ ctf deploy --production
+ [ "$(incus list --all-projects -cn -fcsv | wc -l)" -eq 2 ] || exit 1
+ ctf redeploy --production --tracks mock-track-apache-php
+ [ "$(incus list --all-projects -cn -fcsv | wc -l)" -eq 2 ] || exit 1
+ ctf destroy --force
+
+ - name: Test deployment of a track not deployed without destroying the rest
+ run: |
+ ctf deploy --production --production --tracks mock-track-apache-php
+ [ "$(incus list --all-projects -cn -fcsv | wc -l)" -eq 1 ] || exit 1
+ ctf redeploy --production --tracks mock-track-python-service
+ [ "$(incus list --all-projects -cn -fcsv | wc -l)" -eq 2 ] || exit 1
+ ctf destroy --force
+
+ - name: Check deployment results
+ run: |
+ incus project list
+ incus network zone record list ctf
+ incus network list --all-projects
+ incus list --all-projects
diff --git a/.gitignore b/.gitignore
index 0a19790..0a47dac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,174 +1,21 @@
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
+*.swp
-# C extensions
-*.so
+.deploy/modules.tf
+.deploy/.terraform
+.deploy/.terraform.*
+.deploy/terraform.tfstate*
-# 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
+challenges/*/terraform/.terraform
+challenges/*/terraform/.terraform.*
+challenges/*/terraform/terraform.tfstate*
+challenges/*/terraform/variables.tf
+challenges/*/terraform/versions.tf
-# 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
+# For Python and Virtual Environment. Create your virtual environ in a folder named "venv".
+**/__pycache__/
+**/venv/**
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
+*.egg-info
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
+.vscode/
-# 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
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-#pdm.lock
-# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
-# in version control.
-# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
-.pdm.toml
-.pdm-python
-.pdm-build/
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.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/
-
-# Ruff stuff:
-.ruff_cache/
-
-# PyPI configuration file
-.pypirc
diff --git a/challenges/mock-track-apache-php/ansible/challenge/index.php b/challenges/mock-track-apache-php/ansible/challenge/index.php
new file mode 100644
index 0000000..6232b39
--- /dev/null
+++ b/challenges/mock-track-apache-php/ansible/challenge/index.php
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/challenges/mock-track-apache-php/ansible/deploy.yaml b/challenges/mock-track-apache-php/ansible/deploy.yaml
new file mode 100644
index 0000000..bac1961
--- /dev/null
+++ b/challenges/mock-track-apache-php/ansible/deploy.yaml
@@ -0,0 +1,50 @@
+- name: "Install Apache2 and PHP on each host"
+ hosts: "*"
+ vars_files:
+ - ../track.yaml
+ tasks:
+ - name: "Load flags"
+ loop: "{{ flags }}"
+ vars:
+ key: "{{ (item.tags).discourse }}"
+ value: "{{ item.flag }}"
+ ansible.builtin.set_fact:
+ track_flags: "{{ track_flags | default({}) | combine({key: value}) }}"
+
+ - name: Initial System Upgrade
+ ansible.builtin.apt:
+ update_cache: true
+ install_recommends: false
+ upgrade: full
+
+ - name: Install PHP and Apache2
+ ansible.builtin.apt:
+ name:
+ - php
+ - apache2
+ - libapache2-mod-php
+ state: present
+
+ - name: Remove default file "/var/www/html/index.html"
+ ansible.builtin.file:
+ path: "/var/www/html/index.html"
+ state: absent
+
+ - name: Copy the main site file (index.php)
+ ansible.builtin.template:
+ src: challenge/index.php
+ dest: /var/www/html/
+ owner: root
+ group: root
+ mode: '0644'
+
+ - name: Restart Apache2 on failure
+ ansible.builtin.replace:
+ path: "/lib/systemd/system/apache2.service"
+ regexp: 'Restart=.+$'
+ replace: 'Restart=on-failure'
+
+ - name: Restart Apache2
+ ansible.builtin.service:
+ name: apache2
+ state: restarted
diff --git a/challenges/mock-track-apache-php/ansible/inventory b/challenges/mock-track-apache-php/ansible/inventory
new file mode 100644
index 0000000..b3f3bee
--- /dev/null
+++ b/challenges/mock-track-apache-php/ansible/inventory
@@ -0,0 +1,9 @@
+all:
+ hosts:
+ mock-track-apache-php:
+ ansible_incus_host: mock-track-apache-php
+ vars:
+ ansible_connection: community.general.incus
+ ansible_user: root
+ ansible_incus_remote: local
+ ansible_incus_project: mock-track-apache-php
diff --git a/challenges/mock-track-apache-php/posts/mock-track-apache-php-flag1.yaml b/challenges/mock-track-apache-php/posts/mock-track-apache-php-flag1.yaml
new file mode 100644
index 0000000..130fb98
--- /dev/null
+++ b/challenges/mock-track-apache-php/posts/mock-track-apache-php-flag1.yaml
@@ -0,0 +1,10 @@
+type: post
+topic: mock-track-apache-php
+trigger:
+ type: flag
+ tag: mock_track_apache_php_flag_1
+api:
+ user: nsec
+title: "Mock Track Apache PHP"
+body: |-
+ Mock Track Apache PHP body.
\ No newline at end of file
diff --git a/challenges/mock-track-apache-php/posts/mock-track-apache-php.yaml b/challenges/mock-track-apache-php/posts/mock-track-apache-php.yaml
new file mode 100644
index 0000000..e23c59c
--- /dev/null
+++ b/challenges/mock-track-apache-php/posts/mock-track-apache-php.yaml
@@ -0,0 +1,6 @@
+type: topic
+api:
+ user: nsec
+title: "Mock Track Apache PHP body."
+body: |-
+ Mock Track Apache PHP body.
\ No newline at end of file
diff --git a/ctf/templates/templates/main.tf.j2 b/challenges/mock-track-apache-php/terraform/main.tf
similarity index 52%
rename from ctf/templates/templates/main.tf.j2
rename to challenges/mock-track-apache-php/terraform/main.tf
index 07b617e..eedbe51 100644
--- a/ctf/templates/templates/main.tf.j2
+++ b/challenges/mock-track-apache-php/terraform/main.tf
@@ -1,4 +1,3 @@
-# AUTOGENERATED - No need to change this section #
resource "incus_project" "this" {
remote = var.incus_remote
@@ -13,7 +12,6 @@ resource "incus_project" "this" {
}
}
-# AUTOGENERATED - No need to change this section #
resource "incus_network" "this" {
remote = var.incus_remote
project = incus_project.this.name
@@ -23,12 +21,11 @@ resource "incus_network" "this" {
config = {
"ipv4.address" = var.deploy == "production" ? "none" : null
- "ipv6.address" = "{{ data.ipv6_subnet }}::1/64"
+ "ipv6.address" = "9000:d37e:c40b:c5e8::1/64"
"ipv6.nat" = var.deploy == "production" ? "false" : "true"
}
}
-# Some settings can be changed here #
resource "incus_profile" "this" {
remote = var.incus_remote
project = incus_project.this.name
@@ -37,7 +34,6 @@ resource "incus_profile" "this" {
description = "Default profile for containers in the ${local.track.name} track"
config = {
- # These limits should only be adjusted if you NEED more resources.
"limits.cpu" = "2"
"limits.memory" = "256MiB"
"limits.processes" = "2000"
@@ -53,32 +49,23 @@ resource "incus_profile" "this" {
properties = {
"pool" = "default"
"path" = "/"
- # This limit should only be adjusted if you NEED more resources.
"size" = "1GiB"
}
}
}
-# !!!! Modify this section as needed to match what you need. !!!! #
locals {
instances = {
- # `{{ data.name }}` can be changed to something more relevant
- {{ data.name }} = {
- "description" : "CHANGE_MEMain apache + PHP website",
- # This second half of a MAC address was automatically generated. The first half is just below
- "hwaddr" : "{{ data.hardware_address }}",
- # This is the subdomain value for this machine. Do NOT include .ctf, it will be automatically added.
- # For example, if the value of the record is a.b.c, a DNS record will be created for a.b.c.ctf
- "record" : "{{ data.name }}",
- # This second half of the IPv6 is derived from the hwaddr.
- "ipv6" : "{{ data.ipv6 }}"
+ mock-track-apache-php = {
+ "description" : "Main apache + PHP website",
+ "hwaddr" : "00:16:3e:d5:1c:a1",
+ "record" : "mock-track-apache-php",
+ "ipv6" : "216:3eff:fed5:1ca1"
}
- # You can add more machines here.
}
}
-# AUTOGENERATED - No need to change this section #
resource "incus_instance" "this" {
remote = var.incus_remote
project = incus_project.this.name
@@ -87,7 +74,7 @@ resource "incus_instance" "this" {
name = each.key
- image = "images:ubuntu/24.04"
+ image = "images:debian/13"
profiles = ["default", incus_profile.this.name]
device {
@@ -106,11 +93,7 @@ resource "incus_instance" "this" {
}
}
-# AUTOGENERATED - No need to change this section #
resource "incus_network_zone_record" "this" {
- remote = var.incus_remote
-
- # This resource is generated for each instances (the `locals` section of this file)
for_each = local.instances
zone = "ctf"
@@ -119,26 +102,8 @@ resource "incus_network_zone_record" "this" {
description = each.value["description"]
entry {
- type = "AAAA"
- ttl = 3600
- # The last 2 bytes of the first half of this IP ({{ data.iov6_subnet }}) have been generated randomly to prevent collisions with other track networks.
- value = "{{ data.ipv6_subnet }}:${each.value["ipv6"]}"
+ type = "AAAA"
+ ttl = 3600
+ value = "9000:d37e:c40b:c5e8:${each.value["ipv6"]}"
}
}
-
-
-# If you need to manually add DNS records, here is an example.
-#resource "incus_network_zone_record" "sub" {
-# remote = var.incus_remote
-#
-# zone = "ctf"
-#
-# name = "sub.chal5.hackademy"
-# description = local.instances["challenge5"]["description"]
-#
-# entry {
-# type = "AAAA"
-# ttl = 3600
-# value = "{{ data.ipv6_subnet }}:${local.instances["challenge5"]["ipv6"]}"
-# }
-#}
diff --git a/challenges/mock-track-apache-php/terraform/variables.tf b/challenges/mock-track-apache-php/terraform/variables.tf
new file mode 120000
index 0000000..3644ec7
--- /dev/null
+++ b/challenges/mock-track-apache-php/terraform/variables.tf
@@ -0,0 +1 @@
+../../../.deploy/common/variables.tf
\ No newline at end of file
diff --git a/challenges/mock-track-apache-php/terraform/versions.tf b/challenges/mock-track-apache-php/terraform/versions.tf
new file mode 120000
index 0000000..4abc79f
--- /dev/null
+++ b/challenges/mock-track-apache-php/terraform/versions.tf
@@ -0,0 +1 @@
+../../../.deploy/common/versions.tf
\ No newline at end of file
diff --git a/challenges/mock-track-apache-php/track.yaml b/challenges/mock-track-apache-php/track.yaml
new file mode 100644
index 0000000..b174f5b
--- /dev/null
+++ b/challenges/mock-track-apache-php/track.yaml
@@ -0,0 +1,23 @@
+name: mock-track-apache-php
+description: "The hackiest hackers hacking hackily ever after"
+integrated_with_scenario: false
+contacts:
+ dev:
+ - user_dev
+ qa:
+ - user_qa
+ support:
+ - user_support
+flags:
+ - flag: FLAG-e759cb55ec2383b86e1358905f179e8e
+ value: 5
+ description: Free flag in source of index.php
+ return_string: '[mock-track-apache-php] 1/1 Good job! Track completed.'
+ tags:
+ discourse: mock_track_apache_php_flag_1
+services:
+ - name: HTTP-80
+ instance: mock-track-apache-php
+ address: '9000:d37e:c40b:c5e8:216:3eff:fed5:1ca1'
+ port: 80
+ check: http
diff --git a/challenges/mock-track-files-only/posts/mock-track-files-only-flag1.yaml b/challenges/mock-track-files-only/posts/mock-track-files-only-flag1.yaml
new file mode 100644
index 0000000..55181c7
--- /dev/null
+++ b/challenges/mock-track-files-only/posts/mock-track-files-only-flag1.yaml
@@ -0,0 +1,10 @@
+type: post
+topic: mock-track-files-only
+trigger:
+ type: flag
+ tag: mock_track_files_only_flag_1
+api:
+ user: nsec
+title: "Mock Track Files Only"
+body: |-
+ Mock Track Files Only body
\ No newline at end of file
diff --git a/challenges/mock-track-files-only/posts/mock-track-files-only.yaml b/challenges/mock-track-files-only/posts/mock-track-files-only.yaml
new file mode 100644
index 0000000..2702141
--- /dev/null
+++ b/challenges/mock-track-files-only/posts/mock-track-files-only.yaml
@@ -0,0 +1,6 @@
+type: topic
+api:
+ user: nsec
+title: "Mock Track Files Only"
+body: |-
+ Mock Track Files Only body
\ No newline at end of file
diff --git a/challenges/mock-track-files-only/track.yaml b/challenges/mock-track-files-only/track.yaml
new file mode 100644
index 0000000..8e2c5f8
--- /dev/null
+++ b/challenges/mock-track-files-only/track.yaml
@@ -0,0 +1,18 @@
+name: mock-track-files-only
+description: "The hackiest hackers hacking hackily ever after"
+integrated_with_scenario: false
+contacts:
+ dev:
+ - user_dev
+ qa:
+ - user_qa
+ support:
+ - user_support
+flags:
+ - flag: FLAG-32c335e18b6c0eb6ddbd6366ade11069
+ value: 5
+ description: Free flag in downloadable content
+ return_string: '[mock-track-files-only] 1/1 Good job! Track completed.'
+ tags:
+ discourse: mock_track_files_only_flag_1
+services: []
diff --git a/ctf/templates/templates/app.py.j2 b/challenges/mock-track-python-service/ansible/challenge/app.py
similarity index 93%
rename from ctf/templates/templates/app.py.j2
rename to challenges/mock-track-python-service/ansible/challenge/app.py
index 49474e2..96654bb 100644
--- a/ctf/templates/templates/app.py.j2
+++ b/challenges/mock-track-python-service/ansible/challenge/app.py
@@ -19,7 +19,7 @@ def index():
with open("flag-1.txt", "r") as f:
return f"""
- {{data.name}}
+ mock-track-python-service
@@ -29,4 +29,4 @@ def index():
-"""
+"""
\ No newline at end of file
diff --git a/challenges/mock-track-python-service/ansible/challenge/flag-1.txt b/challenges/mock-track-python-service/ansible/challenge/flag-1.txt
new file mode 100644
index 0000000..8111c03
--- /dev/null
+++ b/challenges/mock-track-python-service/ansible/challenge/flag-1.txt
@@ -0,0 +1 @@
+{{ track_flags.mock_track_python_service_flag_1 }} (1/2)
diff --git a/ctf/templates/templates/deploy-python-service.yaml.j2 b/challenges/mock-track-python-service/ansible/deploy.yaml
similarity index 56%
rename from ctf/templates/templates/deploy-python-service.yaml.j2
rename to challenges/mock-track-python-service/ansible/deploy.yaml
index e57e8a4..77462bd 100644
--- a/ctf/templates/templates/deploy-python-service.yaml.j2
+++ b/challenges/mock-track-python-service/ansible/deploy.yaml
@@ -1,22 +1,16 @@
-# This is the main ansible script to deploy the challenge.
-
-# Example on how to run stuff on all hosts of the track
- name: "Install Python, PIP and VirtualEnvironment on each host"
hosts: "*"
vars_files:
- ../track.yaml
tasks:
- # This is a helper task that loads the tracks' `track.yaml` file and loads the flags as
- # ansible facts (like variables) to use in subsequent steps. The key is the `discourse` tag
- # of the flag. See the "Create flag file" task later in this file for an example on how to use/print the flags.
- name: "Load flags"
- loop: "{{ '{{ flags }}' }}"
+ loop: "{{ flags }}"
vars:
- key: "{{ '{{ (item.tags).discourse }}' }}"
- value: "{{ '{{ item.flag }}' }}"
+ key: "{{ (item.tags).discourse }}"
+ value: "{{ item.flag }}"
ansible.builtin.set_fact:
- track_flags: "{{ '{{ track_flags | default({}) | combine({key: value}) }}' }}"
-
+ track_flags: "{{ track_flags | default({}) | combine({key: value}) }}"
+
- name: Initial System Upgrade
ansible.builtin.apt:
update_cache: true
@@ -31,10 +25,6 @@
- virtualenv
state: present
-# Run this only on the `{{ data.name }}` host. Sets up apache2 and php.
-- name: Specific tasks for "{{ data.name }}"
- hosts: "{{ data.name }}"
- tasks:
- name: Create service user
ansible.builtin.user:
name: service
@@ -42,12 +32,12 @@
state: present
- name: Copy the application
- ansible.builtin.copy:
- src: "challenge/{{ '{{ item.src }}' }}"
+ ansible.builtin.template:
+ src: "challenge/{{ item.src }}"
dest: /home/service/
- owner: "{{ '{{ item.owner }}' }}"
- group: "{{ '{{ item.group }}' }}"
- mode: "{{ '{{ item.mode }}' }}"
+ owner: "{{ item.owner }}"
+ group: "{{ item.group }}"
+ mode: "{{ item.mode }}"
loop:
- src: app.py
owner: root
@@ -66,16 +56,13 @@
name:
- flask
- # Example on how to use flags that were loaded in the "Load Flags" task earlier in the file.
- name: Create flag file
ansible.builtin.copy:
dest: /home/service/flag-rce.txt
owner: service
group: service
mode: '0644'
- content: |
- {{ '{{' }} track_flags.{{ data.name | replace("-","_") }}_flag_1 {{ '}}' }} (2/2)
- {{ '{{ "" }}' }}
+ content: "{{ track_flags.mock_track_python_service_flag_2 }} (2/2)\n"
- name: Create systemctl service
ansible.builtin.copy:
@@ -106,10 +93,3 @@
state: restarted
enabled: true
daemon_reload: true
-
-# If you have many servers in your track with different deployments, it's probably better to separate them in ansible playbooks and import them like this.
-# - import_playbook: main-website.yaml
-# - import_playbook: challenge-robots.yaml
-# - import_playbook: challenge-lfi.yaml
-# - import_playbook: challenge-xxe.yaml
-
\ No newline at end of file
diff --git a/challenges/mock-track-python-service/ansible/inventory b/challenges/mock-track-python-service/ansible/inventory
new file mode 100644
index 0000000..5a0d01c
--- /dev/null
+++ b/challenges/mock-track-python-service/ansible/inventory
@@ -0,0 +1,9 @@
+all:
+ hosts:
+ mock-track-python-service:
+ ansible_incus_host: mock-track-python-service
+ vars:
+ ansible_connection: community.general.incus
+ ansible_user: root
+ ansible_incus_remote: local
+ ansible_incus_project: mock-track-python-service
diff --git a/challenges/mock-track-python-service/posts/mock-track-python-service-flag1.yaml b/challenges/mock-track-python-service/posts/mock-track-python-service-flag1.yaml
new file mode 100644
index 0000000..2bd32db
--- /dev/null
+++ b/challenges/mock-track-python-service/posts/mock-track-python-service-flag1.yaml
@@ -0,0 +1,10 @@
+type: post
+topic: mock-track-python-service
+trigger:
+ type: flag
+ tag: mock_track_python_service_flag_1
+api:
+ user: nsec
+title: "Mock Track Python Service"
+body: |-
+ Mock Track Python Service body
\ No newline at end of file
diff --git a/challenges/mock-track-python-service/posts/mock-track-python-service-flag2.yaml b/challenges/mock-track-python-service/posts/mock-track-python-service-flag2.yaml
new file mode 100644
index 0000000..2d06687
--- /dev/null
+++ b/challenges/mock-track-python-service/posts/mock-track-python-service-flag2.yaml
@@ -0,0 +1,10 @@
+type: post
+topic: mock-track-python-service
+trigger:
+ type: flag
+ tag: mock_track_python_service_flag_2
+api:
+ user: nsec
+title: "Mock Track Python Service"
+body: |-
+ Mock Track Python Service body
\ No newline at end of file
diff --git a/ctf/templates/templates/topic.yaml.j2 b/challenges/mock-track-python-service/posts/mock-track-python-service.yaml
similarity index 52%
rename from ctf/templates/templates/topic.yaml.j2
rename to challenges/mock-track-python-service/posts/mock-track-python-service.yaml
index fa6ec34..516957b 100644
--- a/ctf/templates/templates/topic.yaml.j2
+++ b/challenges/mock-track-python-service/posts/mock-track-python-service.yaml
@@ -2,6 +2,6 @@
type: topic
api:
user: nsec
-title: "Scenario-integrated title CHANGE_ME"
+title: "Mock Track Python Service"
body: |-
- CHANGE_ME Markdown supported
+ Mock Track Python Service body
\ No newline at end of file
diff --git a/challenges/mock-track-python-service/terraform/main.tf b/challenges/mock-track-python-service/terraform/main.tf
new file mode 100644
index 0000000..5616df7
--- /dev/null
+++ b/challenges/mock-track-python-service/terraform/main.tf
@@ -0,0 +1,109 @@
+resource "incus_project" "this" {
+ remote = var.incus_remote
+
+ name = local.track.name
+ description = "Project for the ${local.track.name} track"
+
+ config = {
+ "features.images" = "false"
+ "user.contacts.dev" = join(", ", local.track.contacts.dev)
+ "user.contacts.qa" = join(", ", local.track.contacts.qa)
+ "user.contacts.support" = join(", ", local.track.contacts.support)
+ }
+}
+
+resource "incus_network" "this" {
+ remote = var.incus_remote
+ project = incus_project.this.name
+
+ name = substr(local.track.name, 0, 15)
+ description = "Network for challenges in the ${local.track.name} track"
+
+ config = {
+ "ipv4.address" = var.deploy == "production" ? "none" : null
+ "ipv6.address" = "9000:d37e:c40b:b29a::1/64"
+ "ipv6.nat" = var.deploy == "production" ? "false" : "true"
+ }
+}
+
+resource "incus_profile" "this" {
+ remote = var.incus_remote
+ project = incus_project.this.name
+
+ name = "containers"
+ description = "Default profile for containers in the ${local.track.name} track"
+
+ config = {
+ "limits.cpu" = "2"
+ "limits.memory" = "256MiB"
+ "limits.processes" = "2000"
+ "environment.http_proxy" = var.deploy == "production" ? "http://proxy.ctf-int.internal.nsec.io:3128" : null
+ "environment.https_proxy" = var.deploy == "production" ? "http://proxy.ctf-int.internal.nsec.io:3128" : null
+ }
+
+
+ device {
+ name = "root"
+ type = "disk"
+
+ properties = {
+ "pool" = "default"
+ "path" = "/"
+ "size" = "1GiB"
+ }
+ }
+}
+
+
+locals {
+ instances = {
+ mock-track-python-service = {
+ "description" : "Python service website",
+ "hwaddr" : "00:16:3e:09:23:ea",
+ "record" : "mock-track-python-service",
+ "ipv6" : "216:3eff:fe09:23ea"
+ }
+ }
+}
+
+resource "incus_instance" "this" {
+ remote = var.incus_remote
+ project = incus_project.this.name
+
+ for_each = local.instances
+
+ name = each.key
+
+ image = "images:debian/13"
+ profiles = ["default", incus_profile.this.name]
+
+ device {
+ name = "eth0"
+ type = "nic"
+
+ properties = {
+ "network" = incus_network.this.name
+ "name" = "eth0"
+ "hwaddr" = "${each.value["hwaddr"]}"
+ }
+ }
+
+ lifecycle {
+ ignore_changes = [running]
+ }
+}
+
+resource "incus_network_zone_record" "this" {
+ for_each = local.instances
+
+ zone = "ctf"
+
+ name = each.value["record"]
+ description = each.value["description"]
+
+ entry {
+ type = "AAAA"
+ ttl = 3600
+ value = "9000:d37e:c40b:b29a:${each.value["ipv6"]}"
+ }
+}
diff --git a/challenges/mock-track-python-service/terraform/variables.tf b/challenges/mock-track-python-service/terraform/variables.tf
new file mode 120000
index 0000000..3644ec7
--- /dev/null
+++ b/challenges/mock-track-python-service/terraform/variables.tf
@@ -0,0 +1 @@
+../../../.deploy/common/variables.tf
\ No newline at end of file
diff --git a/challenges/mock-track-python-service/terraform/versions.tf b/challenges/mock-track-python-service/terraform/versions.tf
new file mode 120000
index 0000000..4abc79f
--- /dev/null
+++ b/challenges/mock-track-python-service/terraform/versions.tf
@@ -0,0 +1 @@
+../../../.deploy/common/versions.tf
\ No newline at end of file
diff --git a/challenges/mock-track-python-service/track.yaml b/challenges/mock-track-python-service/track.yaml
new file mode 100644
index 0000000..1328bca
--- /dev/null
+++ b/challenges/mock-track-python-service/track.yaml
@@ -0,0 +1,29 @@
+name: mock-track-python-service
+description: "The hackiest hackers hacking hackily ever after"
+integrated_with_scenario: false
+contacts:
+ dev:
+ - user_dev
+ qa:
+ - user_qa
+ support:
+ - user_support
+flags:
+ - flag: FLAG-d2093796194fdf44a39c8b21b00caef9
+ value: 2
+ description: Free flag in source of web application
+ return_string: '[mock-track-python-service] 1/2 Great!'
+ tags:
+ discourse: mock_track_python_service_flag_1
+ - flag: FLAG-20f645f09e6989741a39759209aa047d
+ value: 10
+ description: RCE!!
+ return_string: '[mock-track-python-service] 2/2 Good job! Track completed.'
+ tags:
+ discourse: mock_track_python_service_flag_2
+services:
+ - name: HTTP-5000
+ instance: mock-track-python-service
+ address: '9000:d37e:c40b:b29a:216:3eff:fe09:23ea'
+ port: 5000
+ check: http
\ No newline at end of file
diff --git a/ctf/__init__.py b/ctf/__init__.py
index f177ac7..be3d74b 100644
--- a/ctf/__init__.py
+++ b/ctf/__init__.py
@@ -53,10 +53,11 @@ def check_tool_version():
case 0 | 1:
LOG.debug("Script is up to date.")
case -1:
- LOG.critical(
+ LOG.warning(
"Script is outdated. Please update to the latest release before continuing."
)
- exit(code=1)
+ if (input("Do you want to continue? [y/N] ").lower() or "n") == "n":
+ exit(code=0)
check_tool_version()
diff --git a/ctf/__main__.py b/ctf/__main__.py
index fd1e022..5b85824 100644
--- a/ctf/__main__.py
+++ b/ctf/__main__.py
@@ -3,6 +3,7 @@
import csv
import io
import json
+import logging
import os
import re
import secrets
@@ -10,6 +11,7 @@
import statistics
import subprocess
import textwrap
+from datetime import datetime
from enum import Enum, unique
import argcomplete
@@ -45,6 +47,13 @@
except ImportError:
_has_pybadges = False
+try:
+ import matplotlib.pyplot as plt
+
+ _has_matplotlib = True
+except ImportError:
+ _has_matplotlib = False
+
TEMPLATES_ROOT_DIRECTORY = get_ctf_script_templates_directory()
SCHEMAS_ROOT_DIRECTORY = get_ctf_script_schemas_directory()
AVAILABLE_INCUS_REMOTES = available_incus_remotes()
@@ -72,17 +81,6 @@ def __str__(self) -> str:
return self.value
-def requires_pybadges(f):
- def wrapper(*args, **kwargs):
- if not _has_pybadges:
- LOG.critical(msg="Module pybadges was not found.")
- exit(code=1)
-
- f(*args, **kwargs)
-
- return wrapper
-
-
def terraform_binary() -> str:
path = shutil.which(cmd="tofu")
if not path:
@@ -190,7 +188,7 @@ def new(args: argparse.Namespace) -> None:
track_template = env.get_template(name="post.yaml.j2")
render = track_template.render(data={"name": args.name})
with open(
- file=(p := os.path.join(posts_directory, "flag1.yaml")),
+ file=(p := os.path.join(posts_directory, f"{args.name}_flag1.yaml")),
mode="w",
encoding="utf-8",
) as f:
@@ -317,7 +315,7 @@ def new(args: argparse.Namespace) -> None:
mode="w",
encoding="utf-8",
) as f:
- f.write("FLAG-CHANGE_ME (1/2)\n")
+ f.write(f"{{{{ track_flags.{args.name}_flag_1 }}}} (1/2)\n")
LOG.debug(msg=f"Wrote {p}.")
@@ -538,6 +536,38 @@ def flags(args: argparse.Namespace) -> None:
print(yaml.safe_dump(data=flags))
+def services(args: argparse.Namespace) -> None:
+ tracks = set()
+ for entry in os.listdir(
+ path=(challenges_directory := os.path.join(CTF_ROOT_DIRECTORY, "challenges"))
+ ):
+ if os.path.isdir(
+ s=(track_directory := os.path.join(challenges_directory, entry))
+ ) and os.path.exists(path=os.path.join(track_directory, "track.yaml")):
+ if not args.tracks:
+ tracks.add(entry)
+ elif entry in args.tracks:
+ tracks.add(entry)
+
+ for track in tracks:
+ LOG.debug(msg=f"Parsing track.yaml for track {track}")
+ track_yaml = parse_track_yaml(track_name=track)
+
+ if len(track_yaml["services"]) == 0:
+ LOG.debug(msg=f"No service in track {track}. Skipping...")
+ continue
+
+ for service in track_yaml["services"]:
+ contact = ",".join(track_yaml["contacts"]["support"])
+ name = service["name"]
+ instance = service["instance"]
+ address = service["address"]
+ check = service["check"]
+ port = service["port"]
+
+ print(f"{track}/{instance}/{name} {contact} {address} {check} {port}")
+
+
def generate(args: argparse.Namespace) -> set[str]:
# Get the list of tracks.
tracks = set(
@@ -785,10 +815,10 @@ def run_ansible_playbook(args: argparse.Namespace, track: str, path: str) -> Non
if args.production:
extra_args += ["-e", "nsec_production=true"]
- LOG.info(msg=f"Running common cleanup.yaml with ansible for track {track}...")
+ LOG.info(msg=f"Running common yaml with ansible for track {track}...")
ansible_args = [
"ansible-playbook",
- "../../../.deploy/cleanup.yaml",
+ "../../../.deploy/common.yaml",
"-i",
"inventory",
] + extra_args
@@ -840,7 +870,6 @@ def check(args: argparse.Namespace) -> None:
)
-@requires_pybadges
def stats(args: argparse.Namespace) -> None:
LOG.debug(msg="Generating statistics...")
stats = {}
@@ -871,6 +900,7 @@ def stats(args: argparse.Namespace) -> None:
stats["number_of_challenge_designers"] = 0
stats["number_of_flags_per_track"] = {}
stats["number_of_points_per_track"] = {}
+ stats["not_integrated_with_scenario"] = []
challenge_designers = set()
flags = []
for track in tracks:
@@ -879,6 +909,8 @@ def stats(args: argparse.Namespace) -> None:
stats["number_of_flags_per_track"][track] = number_of_flags
if track_yaml["integrated_with_scenario"]:
stats["number_of_tracks_integrated_with_scenario"] += 1
+ else:
+ stats["not_integrated_with_scenario"].append(track)
if number_of_flags > stats["most_flags_in_a_track"]:
stats["most_flags_in_a_track"] = number_of_flags
stats["number_of_flags"] += number_of_flags
@@ -929,6 +961,9 @@ def stats(args: argparse.Namespace) -> None:
print(json.dumps(stats, indent=2, ensure_ascii=False))
if args.generate_badges:
+ if not _has_pybadges:
+ LOG.critical(msg="Module pybadges was not found.")
+ exit(code=1)
LOG.info(msg="Generating badges...")
os.makedirs(name=".badges", exist_ok=True)
write_badge(
@@ -977,6 +1012,131 @@ def stats(args: argparse.Namespace) -> None:
),
)
+ if args.charts:
+ if not _has_matplotlib:
+ LOG.critical(msg="Module matplotlib was not found.")
+ exit(code=1)
+ LOG.info(msg="Generating charts...")
+ mpl_logger = logging.getLogger("matplotlib")
+ mpl_logger.setLevel(logging.INFO)
+ os.makedirs(name=".charts", exist_ok=True)
+ # Flag count per value barchart
+ plt.bar(
+ stats["flag_count_per_value"].keys(), stats["flag_count_per_value"].values()
+ )
+ plt.xticks(
+ ticks=range(0, max(stats["flag_count_per_value"].keys()) + 1), rotation=45
+ )
+ plt.grid(True, linestyle="--", alpha=0.3)
+ plt.xlabel("Flag Value")
+ plt.ylabel("Number of Flags")
+ plt.title("Number of Flags per Value")
+ plt.savefig(os.path.join(".charts", "flags_per_value.png"))
+ plt.clf()
+
+ # Number of flag per track barchart
+ plt.bar(
+ list(stats["number_of_flags_per_track"].keys()),
+ stats["number_of_flags_per_track"].values(),
+ )
+ plt.xticks(ticks=list(stats["number_of_flags_per_track"].keys()), rotation=90)
+ plt.grid(True, linestyle="--", alpha=0.3)
+ plt.subplots_adjust(bottom=0.5)
+ plt.xlabel("Track")
+ plt.ylabel("Number of flags")
+ plt.title("Number of flags per track")
+ plt.savefig(os.path.join(".charts", "flags_per_track.png"))
+ plt.clf()
+
+ # Number of points per track barchart
+ plt.bar(
+ list(stats["number_of_points_per_track"].keys()),
+ stats["number_of_points_per_track"].values(),
+ )
+ plt.xticks(ticks=list(stats["number_of_points_per_track"].keys()), rotation=90)
+ plt.grid(True, linestyle="--", alpha=0.3)
+ plt.subplots_adjust(bottom=0.5)
+ plt.xlabel("Track")
+ plt.ylabel("Number of points")
+ plt.title("Number of points per track")
+ plt.savefig(os.path.join(".charts", "points_per_track.png"))
+ plt.clf()
+
+ if args.historical:
+ # Number of points and flags over time
+ historical_data = {}
+ commit_list = (
+ subprocess.check_output(
+ ["git", "log", "--pretty=format:%H %ad", "--date=iso"]
+ )
+ .decode()
+ .splitlines()[::-1]
+ )
+ commit_list_with_date = []
+ for commit in commit_list:
+ hash, date = commit.split(" ", 1)
+ parsed_datetime = datetime.strptime(date, "%Y-%m-%d %H:%M:%S %z")
+ commit_list_with_date.append((parsed_datetime, hash))
+ commit_list_with_date = sorted(commit_list_with_date, key=lambda x: x[0])
+ subprocess.run(["git", "stash"], check=True)
+ for i, commit in list(enumerate(commit_list_with_date))[0:]:
+ parsed_datetime, hash = commit
+ # Check if the commit message has "Merge pull request" in it
+ commit_message = subprocess.run(
+ ["git", "show", "-s", "--pretty=%B", hash],
+ check=True,
+ capture_output=True,
+ )
+ if "Merge pull request" in commit_message.stdout.decode():
+ LOG.debug(
+ f"{i + 1}/{len(commit_list_with_date)} Checking out commit: {commit}"
+ )
+ parsed_date = parsed_datetime.date()
+ subprocess.run(
+ ["git", "checkout", hash], check=True, capture_output=True
+ )
+
+ # Execute your command here (replace with what you need)
+ result = (
+ subprocess.run(
+ ["python", "scripts/ctf.py", "stats"],
+ check=False,
+ capture_output=True,
+ text=True,
+ ),
+ )
+ if result[0].returncode == 0:
+ stats = json.loads(result[0].stdout)
+ total_points = stats["total_flags_value"]
+ total_flags = stats["number_of_flags"]
+ print(total_flags)
+ historical_data[parsed_date] = {
+ "total_points": total_points,
+ "total_flags": total_flags,
+ }
+ subprocess.run(["git", "checkout", "main"], check=True, capture_output=True)
+ subprocess.run(["git", "stash", "pop"], check=True)
+
+ plt.plot(
+ historical_data.keys(),
+ [data["total_points"] for data in historical_data.values()],
+ label="Total Points",
+ )
+ # plt.plot(historical_data.keys(), [data["total_flags"] for data in historical_data.values()], label="Total Flags")
+ # plt.xticks(ticks=list(stats["number_of_points_per_track"].keys()), rotation=90)
+ plt.grid(True, linestyle="--", alpha=0.3)
+ plt.subplots_adjust(bottom=0.1)
+ plt.xlabel("Time")
+ plt.ylabel("Total points")
+ plt.title("Total points over time")
+ plt.xticks(rotation=90)
+ plt.subplots_adjust(bottom=0.2)
+ plt.subplot().set_ylim(
+ 0, max([data["total_points"] for data in historical_data.values()]) + 10
+ )
+ plt.savefig(os.path.join(".charts", "points_over_time.png"))
+ plt.clf()
+
LOG.debug(msg="Done...")
@@ -1171,6 +1331,19 @@ def main():
type=OutputFormat,
)
+ parser_services = subparsers.add_parser(
+ "services",
+ help="Get services from tracks",
+ )
+ parser_services.set_defaults(func=services)
+ parser_services.add_argument(
+ "--tracks",
+ "-t",
+ nargs="+",
+ default=[],
+ help="Only services from the given tracks (use the folder name)",
+ )
+
parser_generate = subparsers.add_parser(
"generate",
help="Generate the deployment files using `terraform init` and `terraform validate`",
@@ -1331,6 +1504,18 @@ def main():
default=False,
help="Generate SVG files of some statistics in the .badges directory.",
)
+ parser_stats.add_argument(
+ "--charts",
+ action="store_true",
+ default=False,
+ help="Generate PNG charts of some statistics in the .charts directory.",
+ )
+ parser_stats.add_argument(
+ "--historical",
+ action="store_true",
+ default=False,
+ help="Use in conjunction with --charts to generate historical data. ONLY USE THIS IF YOU KNOW WHAT YOU ARE DOING. THIS IS BAD CODE THAT WILL FUCK YOUR REPO IN UNEXPECTED WAYS.",
+ )
parser_validate = subparsers.add_parser(
"validate",
diff --git a/ctf/schemas/post.json b/ctf/schemas/post.json
index 83cab0f..f548068 100644
--- a/ctf/schemas/post.json
+++ b/ctf/schemas/post.json
@@ -45,7 +45,7 @@
"user": {
"description": "The discourse user this post is posted by.",
"type": "string",
- "minLength": 1
+ "enum": ["nsec","system","theChief","theMuscle","theFace","theFixer","theMechanic"]
}
},
"required": [
diff --git a/ctf/schemas/schemas/post.json b/ctf/schemas/schemas/post.json
deleted file mode 100644
index f548068..0000000
--- a/ctf/schemas/schemas/post.json
+++ /dev/null
@@ -1,143 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "description": "",
- "type": "object",
- "properties": {
- "type": {
- "description": "Type of post. Use `topic` for the initial message of a track, and use `post` for subsequent posts (for example posts after a flag is submitted). `posts` is for multiple posts in one file (post).",
- "type": "string",
- "enum": [
- "post",
- "posts",
- "topic"
- ]
- },
- "topic": {
- "description": "The discourse topic to post this post into. For a given track, this should always have the same value across posts.",
- "type": "string",
- "minLength": 1
- },
- "trigger": {
- "description": "Defines a custom trigger to post a message after a specific action happened, for example a flag is submitted.",
- "type": "object",
- "properties": {
- "type": {
- "description": "Type of trigger: use `flag` to post after a given flag is submitted (the value of the flag should be set in the `tag` property.)",
- "type": "string",
- "enum": [
- "flag"
- ]
- },
- "tag": {
- "description": "Value of the trigger. For a flag trigger, this is the flag value.",
- "type": "string",
- "minLength": 1
- }
- },
- "required": [
- "type",
- "tag"
- ]
- },
- "api": {
- "type": "object",
- "properties": {
- "user": {
- "description": "The discourse user this post is posted by.",
- "type": "string",
- "enum": ["nsec","system","theChief","theMuscle","theFace","theFixer","theMechanic"]
- }
- },
- "required": [
- "user"
- ]
- },
- "title": {
- "description": "The discourse title of the topic. This should be the same across posts of the same track.",
- "type": "string",
- "minLength": 1
- },
- "body": {
- "description": "Content of the post. Markdown is supported.",
- "type": "string",
- "minLength": 1
- },
- "posts": {
- "description": "",
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "api": {
- "type": "object",
- "properties": {
- "user": {
- "description": "The discourse user this post is posted by.",
- "type": "string",
- "minLength": 1
- }
- },
- "required": [
- "user"
- ]
- },
- "body": {
- "description": "Content of the post. Markdown is supported.",
- "type": "string",
- "minLength": 1
- }
- },
- "required": [
- "api",
- "body"
- ]
- }
- }
- },
- "if": {
- "properties": {
- "type": {
- "const": "topic"
- }
- }
- },
- "then": {
- "required": [
- "type",
- "api",
- "title",
- "body"
- ]
- },
- "else": {
- "if": {
- "properties": {
- "type": {
- "const": "post"
- }
- }
- },
- "then": {
- "required": [
- "type",
- "api",
- "body"
- ]
- },
- "else": {
- "if": {
- "properties": {
- "type": {
- "const": "posts"
- }
- }
- },
- "then": {
- "required": [
- "type",
- "posts"
- ]
- }
- }
- }
-}
\ No newline at end of file
diff --git a/ctf/schemas/schemas/track.yaml.json b/ctf/schemas/schemas/track.yaml.json
deleted file mode 100644
index dde0441..0000000
--- a/ctf/schemas/schemas/track.yaml.json
+++ /dev/null
@@ -1,167 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "description": "",
- "type": "object",
- "additionalProperties": false,
- "properties": {
- "name": {
- "type": "string",
- "minLength": 1,
- "description": "Name of the track."
- },
- "description": {
- "type": "string",
- "minLength": 1,
- "description": "Description of the track."
- },
- "integrated_with_scenario": {
- "type": "boolean",
- "description": "Set to true only if the track has been integrated with the CTF scenario. This means that Eric Boivin wrote/modified the posts."
- },
- "contacts": {
- "description": "Handles or real names. Ex: Émilio Gonzalez or Res260",
- "type": "object",
- "properties": {
- "additionalProperties": false,
- "dev": {
- "description": "Who helped develop the track.",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "type": "string"
- }
- },
- "qa": {
- "description": "Who helped do quality assurance for the track.",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "type": "string"
- }
- },
- "support": {
- "description": "Who can provide support during the CTF for the track.",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "type": "string"
- }
- }
- },
- "required": [
- "dev",
- "qa",
- "support"
- ]
- },
- "services": {
- "description": "List of network services used by the track.",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "required": [
- "name",
- "address",
- "port",
- "check"
- ],
- "properties": {
- "additionalProperties": false,
- "name": {
- "description": "Name of the network servic. Example: Tomcat Web Server",
- "type": "string",
- "minLength": 1
- },
- "address": {
- "description": "IPv6 address of the network service.",
- "type": "string",
- "minLength": 36,
- "maxLength": 39
- },
- "port": {
- "description": "Port number where the network service listens on.",
- "type": "number"
- },
- "check": {
- "description": "Type of check to do to make sure this service is up and alert us during the CTF if it's down.",
- "type": "string",
- "enum": ["http", "https", "ssh", "tcp"]
- },
- "dev_port_mapping": {
- "type": "integer",
- "minimum": 1,
- "maximum": 65535,
- "description": "During development, this service will be accessible from localhost:. This allows to access the services outside the host (for example from the Windows Host if using WSL or from your host machine if using GitHub Codespaces)."
- }
- }
- }
- },
- "flags": {
- "description": "The list of flags in the track.",
- "type": "array",
- "minItems": 0,
- "maxItems": 20,
- "uniqueItems": true,
- "items": {
- "description": "A flag definition.",
- "type": "object",
- "required": ["flag", "value", "return_string"],
- "additionalProperties": false,
- "properties": {
- "flag": {
- "type": "string",
- "description": "The flag string. Ex: FLAG-SalutLaGang.",
- "minLength": 5,
- "maxLength": 200
- },
- "value": {
- "type": "integer",
- "description": "The value of the flag. Minimum: 0.",
- "minimum": 0,
- "maximum": 20
- },
- "description": {
- "type": "string",
- "description": "INTERNAL description of the flag. Example: Free flag in source of index.php.",
- "minLength": 1
- },
- "return_string": {
- "type": "string",
- "description": "The text the participants see AFTER they submit the flag. Example: [mytrackname] 1/1 Good job! Track completed.",
- "minLength": 1
- },
- "tags": {
- "type": "object",
- "description": "Askgod tags for this flag. Use tag `discourse: sometriggername` to define triggers for posts in the posts/ directory.",
- "additionalProperties": true,
- "properties": {
- "discourse": {
- "type": "string",
- "description": "Discourse trigger for this flag. This value can be used in a discourse post defined in the posts/ directory to post a message when this flag is submitted.",
- "pattern": "^[a-z][a-z0-9_]{0,61}[a-z0-9]$"
- },
- "ui_sound": {
- "type": "string",
- "description": "Fireworks sound trigger for this flag. This value can be used to trigger a specific sound in the timeline when this flag is submitted. The chosen file can be any of the default ones which can be found at https://github.com/nsec/askgod-webui/blob/master/components/Fireworks.vue#L72-L88 or new ones added in \"challenges/your-track/files/askgod/sounds/*.mp3\".",
- "pattern": "^[a-zA-Z0-9_-]{1,251}\\.mp3$"
- },
- "ui_gif": {
- "type": "string",
- "description": "Fireworks gif trigger for this flag. This value can be used to trigger a specific gif in the timeline when this flag is submitted. The chosen file can be any of the default ones which can be found at https://github.com/nsec/askgod-webui/blob/master/components/Fireworks.vue#L39-L70 or new ones added in \"challenges/your-track/files/askgod/gifs/*.gif\".",
- "pattern": "^[a-zA-Z0-9_-]{1,251}\\.gif$"
- }
- }
- }
- }
- }
- }
- },
- "required": [
- "name",
- "description",
- "integrated_with_scenario",
- "contacts",
- "services",
- "flags"
- ]
- }
diff --git a/ctf/schemas/track.yaml.json b/ctf/schemas/track.yaml.json
index 739b443..05913b5 100644
--- a/ctf/schemas/track.yaml.json
+++ b/ctf/schemas/track.yaml.json
@@ -1,176 +1,168 @@
{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "description": "",
- "type": "object",
- "additionalProperties": false,
- "properties": {
- "name": {
- "type": "string",
- "minLength": 1,
- "description": "Name of the track."
- },
- "description": {
- "type": "string",
- "minLength": 1,
- "description": "Description of the track."
- },
- "integrated_with_scenario": {
- "type": "boolean",
- "description": "Set to true only if the track has been integrated with the CTF scenario. This means that Eric Boivin wrote/modified the posts."
- },
- "contacts": {
- "description": "Handles or real names. Ex: John Smith or jsmith",
- "type": "object",
- "properties": {
- "additionalProperties": false,
- "dev": {
- "description": "Who helped develop the track.",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "type": "string"
- }
- },
- "qa": {
- "description": "Who helped do quality assurance for the track.",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "type": "string"
- }
- },
- "support": {
- "description": "Who can provide support during the CTF for the track.",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "type": "string"
- }
- }
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Name of the track."
},
- "required": [
- "dev",
- "qa",
- "support"
- ]
- },
- "services": {
- "description": "List of network services used by the track.",
- "type": "array",
- "uniqueItems": true,
- "items": {
- "required": [
- "name",
- "address",
- "port",
- "check"
- ],
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Description of the track."
+ },
+ "integrated_with_scenario": {
+ "type": "boolean",
+ "description": "Set to true only if the track has been integrated with the CTF scenario. This means that Eric Boivin wrote/modified the posts."
+ },
+ "contacts": {
+ "description": "Handles or real names. Ex: Émilio Gonzalez or Res260",
+ "type": "object",
"properties": {
"additionalProperties": false,
- "name": {
- "description": "Name of the network servic. Example: Tomcat Web Server",
- "type": "string",
- "minLength": 1
- },
- "address": {
- "description": "IPv6 address of the network service.",
- "type": "string",
- "minLength": 36,
- "maxLength": 39
- },
- "port": {
- "description": "Port number where the network service listens on.",
- "type": "number"
+ "dev": {
+ "description": "Who helped develop the track.",
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string"
+ }
},
- "check": {
- "description": "Type of check to do to make sure this service is up and alert us during the CTF if it's down.",
- "type": "string",
- "enum": [
- "http",
- "https",
- "ssh",
- "tcp"
- ]
+ "qa": {
+ "description": "Who helped do quality assurance for the track.",
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string"
+ }
},
- "dev_port_mapping": {
- "type": "integer",
- "minimum": 1,
- "maximum": 65535,
- "description": "During development, this service will be accessible from localhost:. This allows to access the services outside the host (for example from the Windows Host if using WSL or from your host machine if using GitHub Codespaces)."
+ "support": {
+ "description": "Who can provide support during the CTF for the track.",
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string"
+ }
}
- }
- }
- },
- "flags": {
- "description": "The list of flags in the track.",
- "type": "array",
- "minItems": 0,
- "maxItems": 20,
- "uniqueItems": true,
- "items": {
- "description": "A flag definition.",
- "type": "object",
+ },
"required": [
- "flag",
- "value",
- "return_string"
- ],
- "additionalProperties": false,
- "properties": {
- "flag": {
- "type": "string",
- "description": "The flag string. Ex: FLAG-SalutLaGang.",
- "minLength": 5,
- "maxLength": 200
- },
- "value": {
- "type": "integer",
- "description": "The value of the flag. Minimum: 0.",
- "minimum": 0,
- "maximum": 20
- },
- "description": {
- "type": "string",
- "description": "INTERNAL description of the flag. Example: Free flag in source of index.php.",
- "minLength": 1
- },
- "return_string": {
- "type": "string",
- "description": "The text the participants see AFTER they submit the flag. Example: [mytrackname] 1/1 Good job! Track completed.",
- "minLength": 1
- },
- "tags": {
- "type": "object",
- "description": "Askgod tags for this flag. Use tag `discourse: sometriggername` to define triggers for posts in the posts/ directory.",
- "additionalProperties": true,
- "properties": {
- "discourse": {
- "type": "string",
- "description": "Discourse trigger for this flag. This value can be used in a discourse post defined in the posts/ directory to post a message when this flag is submitted.",
- "pattern": "^[a-z][a-z0-9_]{0,61}[a-z0-9]$"
- },
- "ui_sound": {
- "type": "string",
- "description": "Fireworks sound trigger for this flag. This value can be used to trigger a specific sound in the timeline when this flag is submitted. The chosen file can be any of the default ones which can be found at https://github.com/nsec/askgod-webui/blob/master/components/Fireworks.vue#L72-L88 or new ones added in \"challenges/your-track/files/askgod/sounds/*.mp3\".",
- "pattern": "^[a-zA-Z0-9_-]{1,251}\\.[a-zA-Z0-9]{2,5}$"
- },
- "ui_gif": {
- "type": "string",
- "description": "Fireworks gif trigger for this flag. This value can be used to trigger a specific gif in the timeline when this flag is submitted. The chosen file can be any of the default ones which can be found at https://github.com/nsec/askgod-webui/blob/master/components/Fireworks.vue#L39-L70 or new ones added in \"challenges/your-track/files/askgod/gifs/*.gif\".",
- "pattern": "^[a-zA-Z0-9_-]{1,251}\\.[a-zA-Z0-9]{2,5}$"
+ "dev",
+ "qa",
+ "support"
+ ]
+ },
+ "services": {
+ "description": "List of network services used by the track.",
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "required": [
+ "name",
+ "address",
+ "port",
+ "check"
+ ],
+ "properties": {
+ "additionalProperties": false,
+ "name": {
+ "description": "Name of the network service. Example: Tomcat Web Server. Must be unique in the track. Only use lowercase letters, numbers and dashes.",
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[a-zA-Z0-9-]+$"
+ },
+ "address": {
+ "description": "IPv6 address of the network service.",
+ "type": "string",
+ "minLength": 36,
+ "maxLength": 39
+ },
+ "port": {
+ "description": "Port number where the network service listens on.",
+ "type": "number"
+ },
+ "check": {
+ "description": "Type of check to do to make sure this service is up and alert us during the CTF if it's down.",
+ "type": "string",
+ "enum": ["http", "https", "ssh", "tcp"]
+ },
+ "dev_port_mapping": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535,
+ "description": "During development, this service will be accessible from localhost:. This allows to access the services outside the host (for example from the Windows Host if using WSL or from your host machine if using GitHub Codespaces)."
+ }
+ }
+ }
+ },
+ "flags": {
+ "description": "The list of flags in the track.",
+ "type": "array",
+ "minItems": 0,
+ "maxItems": 20,
+ "uniqueItems": true,
+ "items": {
+ "description": "A flag definition.",
+ "type": "object",
+ "required": ["flag", "value", "return_string"],
+ "additionalProperties": false,
+ "properties": {
+ "flag": {
+ "type": "string",
+ "description": "The flag string. Ex: FLAG-SalutLaGang.",
+ "minLength": 5,
+ "maxLength": 200
+ },
+ "value": {
+ "type": "integer",
+ "description": "The value of the flag. Minimum: 0.",
+ "minimum": 0,
+ "maximum": 20
+ },
+ "description": {
+ "type": "string",
+ "description": "INTERNAL description of the flag. Example: Free flag in source of index.php.",
+ "minLength": 1
+ },
+ "return_string": {
+ "type": "string",
+ "description": "The text the participants see AFTER they submit the flag. Example: [mytrackname] 1/1 Good job! Track completed.",
+ "minLength": 1
+ },
+ "tags": {
+ "type": "object",
+ "description": "Askgod tags for this flag. Use tag `discourse: sometriggername` to define triggers for posts in the posts/ directory.",
+ "additionalProperties": true,
+ "properties": {
+ "discourse": {
+ "type": "string",
+ "description": "Discourse trigger for this flag. This value can be used in a discourse post defined in the posts/ directory to post a message when this flag is submitted.",
+ "pattern": "^[a-z][a-z0-9_]{0,61}[a-z0-9]$"
+ },
+ "ui_sound": {
+ "type": "string",
+ "description": "Fireworks sound trigger for this flag. This value can be used to trigger a specific sound in the timeline when this flag is submitted. The chosen file can be any of the default ones which can be found at https://github.com/nsec/askgod-webui/blob/master/components/Fireworks.vue#L72-L88 or new ones added in \"challenges/your-track/files/askgod/sounds/*.mp3\".",
+ "pattern": "^[a-zA-Z0-9_-]{1,251}\\.[a-zA-Z0-9]{2,5}$"
+ },
+ "ui_gif": {
+ "type": "string",
+ "description": "Fireworks gif trigger for this flag. This value can be used to trigger a specific gif in the timeline when this flag is submitted. The chosen file can be any of the default ones which can be found at https://github.com/nsec/askgod-webui/blob/master/components/Fireworks.vue#L39-L70 or new ones added in \"challenges/your-track/files/askgod/gifs/*.gif\".",
+ "pattern": "^[a-zA-Z0-9_-]{1,251}\\.[a-zA-Z0-9]{2,5}$"
+ }
}
}
}
}
}
- }
- },
- "required": [
- "name",
- "description",
- "integrated_with_scenario",
- "contacts",
- "services",
- "flags"
- ]
-}
\ No newline at end of file
+ },
+ "required": [
+ "name",
+ "description",
+ "integrated_with_scenario",
+ "contacts",
+ "services",
+ "flags"
+ ]
+ }
diff --git a/ctf/templates/templates/Cargo.toml.j2 b/ctf/templates/Cargo.toml.j2
similarity index 100%
rename from ctf/templates/templates/Cargo.toml.j2
rename to ctf/templates/Cargo.toml.j2
diff --git a/ctf/templates/deploy-apache-php.yaml.j2 b/ctf/templates/deploy-apache-php.yaml.j2
index 4fb4938..7bcb90d 100644
--- a/ctf/templates/deploy-apache-php.yaml.j2
+++ b/ctf/templates/deploy-apache-php.yaml.j2
@@ -37,9 +37,9 @@
path: "/var/www/html/index.html"
state: absent
-# Run this only on the `change-me` host. Sets up apache2 and php.
-- name: Specific tasks for "change-me"
- hosts: "change-me"
+# Run this only on the `{{ data.name }}` host. Sets up apache2 and php.
+- name: Specific tasks for "{{ data.name }}"
+ hosts: "{{ data.name }}"
tasks:
- name: Copy the main site file (index.php)
ansible.builtin.template:
diff --git a/ctf/templates/deploy-python-service.yaml.j2 b/ctf/templates/deploy-python-service.yaml.j2
index baf9254..e57e8a4 100644
--- a/ctf/templates/deploy-python-service.yaml.j2
+++ b/ctf/templates/deploy-python-service.yaml.j2
@@ -31,9 +31,9 @@
- virtualenv
state: present
-# Run this only on the `change-me` host. Sets up apache2 and php.
-- name: Specific tasks for "change-me"
- hosts: "change-me"
+# Run this only on the `{{ data.name }}` host. Sets up apache2 and php.
+- name: Specific tasks for "{{ data.name }}"
+ hosts: "{{ data.name }}"
tasks:
- name: Create service user
ansible.builtin.user:
diff --git a/ctf/templates/templates/deploy-rust-webservice.yaml.j2 b/ctf/templates/deploy-rust-webservice.yaml.j2
similarity index 100%
rename from ctf/templates/templates/deploy-rust-webservice.yaml.j2
rename to ctf/templates/deploy-rust-webservice.yaml.j2
diff --git a/ctf/templates/inventory.j2 b/ctf/templates/inventory.j2
index 50916a4..bfa674e 100644
--- a/ctf/templates/inventory.j2
+++ b/ctf/templates/inventory.j2
@@ -2,9 +2,9 @@
all:
hosts:
# The following line defines how this machine will be refered to in ansible scripts.
- change-me:
- # This one tells ansible that this host is reached using incus, and the name of the machine in incus is `change-me`.
- ansible_incus_host: change-me
+ {{ data.name }}:
+ # This one tells ansible that this host is reached using incus, and the name of the machine in incus is `{{ data.name }}`.
+ ansible_incus_host: {{ data.name }}
# You can set variables here to use in your ansible playbooks. For example, you can set the flags here to set them dynamically when setting up the challenge.
vars:
# Do not change these.
diff --git a/ctf/templates/main.tf.j2 b/ctf/templates/main.tf.j2
index 1c534f7..07b617e 100644
--- a/ctf/templates/main.tf.j2
+++ b/ctf/templates/main.tf.j2
@@ -63,12 +63,13 @@ resource "incus_profile" "this" {
# !!!! Modify this section as needed to match what you need. !!!! #
locals {
instances = {
- # `change-me` should be changed to something more relevant
- change-me = {
+ # `{{ data.name }}` can be changed to something more relevant
+ {{ data.name }} = {
"description" : "CHANGE_MEMain apache + PHP website",
# This second half of a MAC address was automatically generated. The first half is just below
"hwaddr" : "{{ data.hardware_address }}",
- # This is the subdomain value for this machine. for example trackname.ctf
+ # This is the subdomain value for this machine. Do NOT include .ctf, it will be automatically added.
+ # For example, if the value of the record is a.b.c, a DNS record will be created for a.b.c.ctf
"record" : "{{ data.name }}",
# This second half of the IPv6 is derived from the hwaddr.
"ipv6" : "{{ data.ipv6 }}"
@@ -107,6 +108,8 @@ resource "incus_instance" "this" {
# AUTOGENERATED - No need to change this section #
resource "incus_network_zone_record" "this" {
+ remote = var.incus_remote
+
# This resource is generated for each instances (the `locals` section of this file)
for_each = local.instances
@@ -126,6 +129,8 @@ resource "incus_network_zone_record" "this" {
# If you need to manually add DNS records, here is an example.
#resource "incus_network_zone_record" "sub" {
+# remote = var.incus_remote
+#
# zone = "ctf"
#
# name = "sub.chal5.hackademy"
diff --git a/ctf/templates/post.yaml.j2 b/ctf/templates/post.yaml.j2
index e87b96a..737d2b6 100644
--- a/ctf/templates/post.yaml.j2
+++ b/ctf/templates/post.yaml.j2
@@ -8,6 +8,5 @@ trigger:
tag: {{ data.name | replace("-","_") }}_flag_1
api:
user: nsec
-title: "CHANGE_ME"
body: |-
CHANGE_ME
diff --git a/ctf/templates/templates/rust-webservice/.gitignore b/ctf/templates/rust-webservice/.gitignore
similarity index 100%
rename from ctf/templates/templates/rust-webservice/.gitignore
rename to ctf/templates/rust-webservice/.gitignore
diff --git a/ctf/templates/templates/rust-webservice/build.rs b/ctf/templates/rust-webservice/build.rs
similarity index 100%
rename from ctf/templates/templates/rust-webservice/build.rs
rename to ctf/templates/rust-webservice/build.rs
diff --git a/ctf/templates/templates/rust-webservice/client/.gitignore b/ctf/templates/rust-webservice/client/.gitignore
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/.gitignore
rename to ctf/templates/rust-webservice/client/.gitignore
diff --git a/ctf/templates/templates/rust-webservice/client/.npmrc b/ctf/templates/rust-webservice/client/.npmrc
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/.npmrc
rename to ctf/templates/rust-webservice/client/.npmrc
diff --git a/ctf/templates/templates/rust-webservice/client/package.json b/ctf/templates/rust-webservice/client/package.json
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/package.json
rename to ctf/templates/rust-webservice/client/package.json
diff --git a/ctf/templates/templates/rust-webservice/client/src/app.d.ts b/ctf/templates/rust-webservice/client/src/app.d.ts
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/src/app.d.ts
rename to ctf/templates/rust-webservice/client/src/app.d.ts
diff --git a/ctf/templates/templates/rust-webservice/client/src/app.html b/ctf/templates/rust-webservice/client/src/app.html
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/src/app.html
rename to ctf/templates/rust-webservice/client/src/app.html
diff --git a/ctf/templates/rust-webservice/client/src/lib/index.ts b/ctf/templates/rust-webservice/client/src/lib/index.ts
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/ctf/templates/rust-webservice/client/src/lib/index.ts
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/ctf/templates/templates/rust-webservice/client/src/routes/+layout.ts b/ctf/templates/rust-webservice/client/src/routes/+layout.ts
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/src/routes/+layout.ts
rename to ctf/templates/rust-webservice/client/src/routes/+layout.ts
diff --git a/ctf/templates/templates/rust-webservice/client/src/routes/+page.svelte b/ctf/templates/rust-webservice/client/src/routes/+page.svelte
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/src/routes/+page.svelte
rename to ctf/templates/rust-webservice/client/src/routes/+page.svelte
diff --git a/ctf/templates/templates/rust-webservice/client/static/favicon.png b/ctf/templates/rust-webservice/client/static/favicon.png
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/static/favicon.png
rename to ctf/templates/rust-webservice/client/static/favicon.png
diff --git a/ctf/templates/templates/rust-webservice/client/svelte.config.js b/ctf/templates/rust-webservice/client/svelte.config.js
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/svelte.config.js
rename to ctf/templates/rust-webservice/client/svelte.config.js
diff --git a/ctf/templates/templates/rust-webservice/client/tsconfig.json b/ctf/templates/rust-webservice/client/tsconfig.json
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/tsconfig.json
rename to ctf/templates/rust-webservice/client/tsconfig.json
diff --git a/ctf/templates/templates/rust-webservice/client/vite.config.ts b/ctf/templates/rust-webservice/client/vite.config.ts
similarity index 100%
rename from ctf/templates/templates/rust-webservice/client/vite.config.ts
rename to ctf/templates/rust-webservice/client/vite.config.ts
diff --git a/ctf/templates/templates/rust-webservice/src/main.rs b/ctf/templates/rust-webservice/src/main.rs
similarity index 100%
rename from ctf/templates/templates/rust-webservice/src/main.rs
rename to ctf/templates/rust-webservice/src/main.rs
diff --git a/ctf/templates/templates/deploy-apache-php.yaml.j2 b/ctf/templates/templates/deploy-apache-php.yaml.j2
deleted file mode 100644
index 7bcb90d..0000000
--- a/ctf/templates/templates/deploy-apache-php.yaml.j2
+++ /dev/null
@@ -1,72 +0,0 @@
-# This is the main ansible script to deploy the challenge.
-
-# Example on how to run stuff on all hosts of the track
-- name: "Install Apache2 and PHP on each host"
- hosts: "*"
- vars_files:
- - ../track.yaml
- tasks:
- # This is a helper task that loads the tracks' `track.yaml` file and loads the flags as
- # ansible facts (like variables) to use in subsequent steps. The key is the `discourse` tag
- # of the flag. See the index.php file for an example on how to use/print the flags.
- - name: "Load flags"
- loop: "{{ '{{ flags }}' }}"
- vars:
- key: "{{ '{{ (item.tags).discourse }}' }}"
- value: "{{ '{{ item.flag }}' }}"
- ansible.builtin.set_fact:
- track_flags: "{{ '{{ track_flags | default({}) | combine({key: value}) }}' }}"
-
- - name: Initial System Upgrade
- ansible.builtin.apt:
- update_cache: true
- install_recommends: false
- upgrade: full
-
- # apt install list
- - name: Install PHP and Apache2
- ansible.builtin.apt:
- name:
- - php
- - apache2
- - libapache2-mod-php
- state: present
-
- - name: Remove default file "/var/www/html/index.html"
- ansible.builtin.file:
- path: "/var/www/html/index.html"
- state: absent
-
-# Run this only on the `{{ data.name }}` host. Sets up apache2 and php.
-- name: Specific tasks for "{{ data.name }}"
- hosts: "{{ data.name }}"
- tasks:
- - name: Copy the main site file (index.php)
- ansible.builtin.template:
- src: challenge/index.php
- dest: /var/www/html/
- owner: root
- group: root
- mode: '0644'
-
-# If you have many servers in your track with different deployments, it's probably better to separate them in ansible playbooks and import them like this.
-# - import_playbook: main-website.yaml
-# - import_playbook: challenge-robots.yaml
-# - import_playbook: challenge-lfi.yaml
-# - import_playbook: challenge-xxe.yaml
-
-# Configure Apache to restart automatically on all hosts
-- name: "Configure Apache2 on each host and restart it"
- hosts: "*"
- tasks:
- - name: Restart Apache2 on failure
- ansible.builtin.replace:
- path: "/lib/systemd/system/apache2.service"
- regexp: 'Restart=.+$'
- replace: 'Restart=on-failure'
-
- - name: Restart Apache2
- ansible.builtin.service:
- name: apache2
- state: restarted
-
\ No newline at end of file
diff --git a/ctf/templates/templates/index.php.j2 b/ctf/templates/templates/index.php.j2
deleted file mode 100644
index f450773..0000000
--- a/ctf/templates/templates/index.php.j2
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/ctf/templates/templates/inventory.j2 b/ctf/templates/templates/inventory.j2
deleted file mode 100644
index bfa674e..0000000
--- a/ctf/templates/templates/inventory.j2
+++ /dev/null
@@ -1,17 +0,0 @@
-# This YAML file defines all machines that Ansible needs to know about to run playbooks and configure machines.
-all:
- hosts:
- # The following line defines how this machine will be refered to in ansible scripts.
- {{ data.name }}:
- # This one tells ansible that this host is reached using incus, and the name of the machine in incus is `{{ data.name }}`.
- ansible_incus_host: {{ data.name }}
- # You can set variables here to use in your ansible playbooks. For example, you can set the flags here to set them dynamically when setting up the challenge.
- vars:
- # Do not change these.
- ansible_connection: community.general.incus
- ansible_user: root
- ansible_incus_remote: local
- # Name of your track. Do not change.
- ansible_incus_project: {{ data.name }}
-
- # Add variables if needed here.
diff --git a/ctf/templates/templates/post.yaml.j2 b/ctf/templates/templates/post.yaml.j2
deleted file mode 100644
index 737d2b6..0000000
--- a/ctf/templates/templates/post.yaml.j2
+++ /dev/null
@@ -1,12 +0,0 @@
-# This is an example post after submitting a flag.
-type: post
-# This should be the same for all the posts in the whole track and should match the original topic YAML filename.
-topic: {{ data.name }}
-trigger:
- type: flag
- # Name of the discourse tag in track.yaml related to your flag.
- tag: {{ data.name | replace("-","_") }}_flag_1
-api:
- user: nsec
-body: |-
- CHANGE_ME
diff --git a/ctf/templates/templates/track.yaml.j2 b/ctf/templates/templates/track.yaml.j2
deleted file mode 100644
index 477f191..0000000
--- a/ctf/templates/templates/track.yaml.j2
+++ /dev/null
@@ -1,50 +0,0 @@
-name: {{ data.name }}
-description: "CHANGE_ME The hackiest hackers hacking hackily ever after"
-# Set to true when Eric Boivin has fully integrated the track with the scenario, which implies he wrote the discourse posts.
-integrated_with_scenario: false
-
-contacts:
- # Who created the challenges. Use either handles or real names.
- dev:
- - CHANGE_ME
- - CHANGE_ME2
- # Who did/does quality assurance on the challenge
- qa:
- - CHANGE_ME
- - CHANGE_ME2
- # Who can offer support regarding the challenge during the CTF.
- support:
- - CHANGE_ME
- - CHANGE_ME2
-
-flags:
- # The flag string
- - flag: FLAG-CHANGE_ME
- # How many points for the flag
- value: 5
- # INTERNAL description of the flag.
- description: Free flag in source of index.php CHANGE_ME
- # The text the participants see AFTER they submit the flag.
- return_string: '[{{ data.name }}] 1/1 Good job! Track completed. CHANGE_ME'
- tags:
- # Name of the discourse trigger for this flag. If a discourse post in the posts/ directory has this trigger, it will be posted when this flag is submitted.
- # This value can also be used to reference flags in Ansible playbooks. See the "Load Flags" task in deploy.yaml.
- discourse: {{ data.name | replace("-","_") }}_flag_1
-
-{% if data.template == "files-only" %}
-services: []
-{% else %}
-services:
- # List of network services running for the track.
- - name: HTTP
- # The IPv6 address was randomly generated from the MAC Address (hwaddr) and the subnets defined in main.tf.
- # If you need other machines, you can choose another MAC Address and get the IPv6 from the MAC AND the IPv6 subnet ({{ data.ipv6_subnet }}) at https://eui64-calc.princelle.org/
- address: '{{ data.full_ipv6_address }}'
- # On what port does the service listens to
- port: {% if data.template == "python-service" %}5000{% else %}80{% endif %}
- # For automated checks during the CTF. This alerts us if something is wrong with the service.
- check: http
- # During development, this service will be accessible from http://localhost:800. This allows to access the services outside the host (for example from the Windows Host if using WSL or from your host machine if using GitHub Codespaces).
- # This is not required, but provides a very helpful shortcut when using Codespaces or WSL.
- dev_port_mapping: 800
-{% endif %}
\ No newline at end of file
diff --git a/ctf/templates/track.yaml.j2 b/ctf/templates/track.yaml.j2
index aa157ab..c1614b9 100644
--- a/ctf/templates/track.yaml.j2
+++ b/ctf/templates/track.yaml.j2
@@ -1,6 +1,6 @@
name: {{ data.name }}
description: "CHANGE_ME The hackiest hackers hacking hackily ever after"
-# Set to true when the theme designer has fully integrated the track with the scenario, which implies he wrote the discourse posts.
+# Set to true when Eric Boivin has fully integrated the track with the scenario, which implies he wrote the discourse posts.
integrated_with_scenario: false
contacts:
@@ -22,7 +22,7 @@ flags:
- flag: FLAG-CHANGE_ME
# How many points for the flag
value: 5
- # INTERNAL description of the flag.
+ # Description of the flag. This text will be displayed to the participants AFTER they submit the flag if they run the `askgod history` command.
description: Free flag in source of index.php CHANGE_ME
# The text the participants see AFTER they submit the flag.
return_string: '[{{ data.name }}] 1/1 Good job! Track completed. CHANGE_ME'
@@ -36,7 +36,10 @@ services: []
{% else %}
services:
# List of network services running for the track.
- - name: HTTP
+ # Each service name must be unique for a given instance (container/VM).
+ - name: {% if data.template == "python-service" %}HTTP-5000{% else %}HTTP{% endif %}
+ # Name of the container or VM
+ instance: main-site
# The IPv6 address was randomly generated from the MAC Address (hwaddr) and the subnets defined in main.tf.
# If you need other machines, you can choose another MAC Address and get the IPv6 from the MAC AND the IPv6 subnet ({{ data.ipv6_subnet }}) at https://eui64-calc.princelle.org/
address: '{{ data.full_ipv6_address }}'
@@ -47,4 +50,4 @@ services:
# During development, this service will be accessible from http://localhost:800. This allows to access the services outside the host (for example from the Windows Host if using WSL or from your host machine if using GitHub Codespaces).
# This is not required, but provides a very helpful shortcut when using Codespaces or WSL.
dev_port_mapping: 800
-{% endif %}
\ No newline at end of file
+{% endif %}
diff --git a/ctf/validators.py b/ctf/validators.py
index 8f2f567..cd7ed38 100644
--- a/ctf/validators.py
+++ b/ctf/validators.py
@@ -166,7 +166,7 @@ def finalize(self) -> list[ValidationError]:
class DiscoursePostsAskGodTagValidator(Validator):
- """Validate that the triggers used in discourse posts are correctly defined in the discourse tag of each flag in track.yaml. Also validate that each discourse tag is unique."""
+ """Validate that the triggers used in discourse posts are correctly defined in the discourse tag of each flag in track.yaml. Also validate that each discourse tag is unique. Also validates that the topic matches an existing file name in the posts directory."""
def __init__(self):
self.discourse_tags_mapping = {}
@@ -186,6 +186,31 @@ def validate(self, track_name: str) -> list[ValidationError]:
discourse_posts = parse_post_yamls(track_name=track_name)
for discourse_post in discourse_posts:
if discourse_post.get("trigger", {}).get("type", "") == "flag":
+ if not os.path.exists(
+ os.path.join(
+ CTF_ROOT_DIRECTORY,
+ "challenges",
+ track_name,
+ "posts",
+ discourse_post["topic"] + ".yaml",
+ )
+ ):
+ errors.append(
+ ValidationError(
+ error_name="Discourse post topic not found",
+ error_description="The topic of the discourse post does not match any file in the posts directory.",
+ track_name=track_name,
+ details={
+ "Topic": discourse_post["topic"],
+ "Posts directory": os.path.join(
+ CTF_ROOT_DIRECTORY,
+ "challenges",
+ track_name,
+ "posts",
+ ),
+ },
+ )
+ )
if discourse_post["trigger"]["tag"] not in discourse_triggers:
errors.append(
ValidationError(
@@ -303,10 +328,127 @@ def validate(self, track_name: str) -> list[ValidationError]:
return errors
+class DiscourseFileNamesValidator(Validator):
+ """Validate that the discourse posts have unique file names."""
+
+ def __init__(self):
+ self.discourse_posts_mapping = {}
+
+ def validate(self, track_name: str) -> list[ValidationError]:
+ files = []
+
+ # Checking placeholders in posts/*.yaml
+ if os.path.exists(
+ path=(
+ path := os.path.join(
+ CTF_ROOT_DIRECTORY, "challenges", track_name, "posts"
+ )
+ )
+ ):
+ files += list(glob.glob(pathname=os.path.join(path, "*.yaml")))
+
+ for file in files:
+ file_name = os.path.basename(file)
+ if file_name not in self.discourse_posts_mapping:
+ self.discourse_posts_mapping[file_name] = []
+ self.discourse_posts_mapping[file_name].append(track_name)
+
+ return []
+
+ def finalize(self) -> list[ValidationError]:
+ errors: list[ValidationError] = []
+ for file_name, tracks in self.discourse_posts_mapping.items():
+ if len(tracks) > 1:
+ errors.append(
+ ValidationError(
+ error_name="Discourse post file name collision",
+ error_description="Two discourse posts from two different tracks share the same name, creating a collision. One of them must be changed.",
+ track_name="\n".join(tracks),
+ details={"File name": file_name},
+ )
+ )
+ return errors
+
+
+class ServicesValidator(Validator):
+ """Validate that each service in a given track has a unique name within its instance and that it only contains letters, numbers and dashes."""
+
+ def validate(self, track_name: str) -> list[ValidationError]:
+ track_yaml = parse_track_yaml(track_name=track_name)
+ errors: list[ValidationError] = []
+ services = set()
+ for service in track_yaml["services"]:
+ service_name = service["name"]
+ instance_name = service["instance"]
+ service = f"{instance_name}/{service_name}"
+
+ if service in services:
+ errors.append(
+ ValidationError(
+ error_name="Service name collision",
+ error_description="Two services from the same track and instance share the same name, creating a collision. One of them must be changed.",
+ track_name=track_name,
+ details={"Service name": service_name},
+ )
+ )
+ else:
+ services.add(service)
+
+ # Validate that the service name only contains lowercase letters, numbers and dashes
+ if not re.match(r"^[a-zA-Z0-9\-]+$", service_name):
+ errors.append(
+ ValidationError(
+ error_name="Invalid service name",
+ error_description="The service name must only contain letters, numbers and dashes.",
+ track_name=track_name,
+ details={"Service name": service_name},
+ )
+ )
+
+ return errors
+
+
+class OrphanServicesValidator(Validator):
+ """Validate that if there is a service in the track.yaml, there is a terraform directory."""
+
+ def validate(self, track_name: str) -> list[ValidationError]:
+ track_yaml = parse_track_yaml(track_name=track_name)
+ errors: list[ValidationError] = []
+ if track_yaml.get("services"):
+ if not os.path.exists(
+ path=os.path.join(
+ CTF_ROOT_DIRECTORY,
+ "challenges",
+ track_name,
+ "terraform",
+ )
+ ):
+ errors.append(
+ ValidationError(
+ error_name="Orphan service",
+ error_description="A service is defined in track.yaml, but a terraform directory was not found. This indicates that the service might not be needed.",
+ track_name=track_name,
+ details={
+ "Service Name": "\n".join(
+ [
+ service.get("name")
+ for service in track_yaml["services"]
+ ]
+ ),
+ },
+ )
+ )
+
+ return errors
+
+
validators_list = [
FilesValidator,
FlagsValidator,
FireworksAskGodTagValidator,
DiscoursePostsAskGodTagValidator,
PlaceholderValuesValidator,
+ DiscourseFileNamesValidator,
+ ServicesValidator,
+ OrphanServicesValidator,
]
diff --git a/pyproject.toml b/pyproject.toml
index fbdf2f8..c79db9c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,14 +18,14 @@ dependencies = [
"black",
"tabulate==0.9.0",
]
-version = "1.0.2"
+version = "1.1.0"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
[project.optional-dependencies]
-coderunner = ["pybadges", "standard-imghdr ; python_version >= \"3.13\""]
+coderunner = ["pybadges", "matplotlib", "standard-imghdr ; python_version >= \"3.13\""]
[project.scripts]
ctf = "ctf.__main__:main"