diff --git a/.deploy/cleanup.yaml b/.deploy/cleanup.yaml deleted file mode 100644 index 95a7a28..0000000 --- a/.deploy/cleanup.yaml +++ /dev/null @@ -1,135 +0,0 @@ -- 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 deleted file mode 100644 index 34ca777..0000000 --- a/.deploy/common.yaml +++ /dev/null @@ -1,14 +0,0 @@ -- 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 deleted file mode 100644 index 6ae54c7..0000000 --- a/.deploy/common/dns.tf +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 920a2bf..0000000 --- a/.deploy/common/variables.tf +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index e2a285a..0000000 --- a/.deploy/common/versions.tf +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 1b240b3..0000000 --- a/.deploy/track.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Do not delete as this file is required for common/dns.tf -{} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b6c2f67..8ea514a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -124,6 +124,18 @@ jobs: working-directory: test-ctf run: | ctf validate + + - name: CTF stats + # Run this in the test-ctf directory + working-directory: test-ctf + run: | + ctf stats + + - name: CTF list + # Run this in the test-ctf directory + working-directory: test-ctf + run: | + ctf list - name: Deployment check working-directory: test-ctf diff --git a/ctf/__main__.py b/ctf/__main__.py index fb9fac0..d0a7d58 100644 --- a/ctf/__main__.py +++ b/ctf/__main__.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -import argparse import csv import io import json @@ -12,12 +11,14 @@ import subprocess import textwrap from datetime import datetime -from enum import Enum, unique +from enum import StrEnum, unique -import argcomplete import jinja2 +import typer import yaml from tabulate import tabulate +from typer import Typer +from typing_extensions import Annotated from ctf import CTF_ROOT_DIRECTORY, ENV, LOG from ctf.utils import ( @@ -58,27 +59,29 @@ SCHEMAS_ROOT_DIRECTORY = get_ctf_script_schemas_directory() AVAILABLE_INCUS_REMOTES = available_incus_remotes() +app = Typer( + help="CLI tool to manage CTF challenges as code. Run from the root CTF repo directory or set the CTF_ROOT_DIR environment variable to run the tool." +) + @unique -class Template(Enum): +class Template(StrEnum): APACHE_PHP = "apache-php" PYTHON_SERVICE = "python-service" FILES_ONLY = "files-only" TRACK_YAML_ONLY = "track-yaml-only" RUST_WEBSERVICE = "rust-webservice" - def __str__(self) -> str: - return self.value - @unique -class OutputFormat(Enum): +class OutputFormat(StrEnum): JSON = "json" CSV = "csv" YAML = "yaml" - def __str__(self) -> str: - return self.value + +class ListOutputFormat(StrEnum): + PRETTY = "pretty" def terraform_binary() -> str: @@ -92,27 +95,39 @@ def terraform_binary() -> str: return path -def init(args: argparse.Namespace) -> None: +@app.command( + help="Initialize a directory with the default CTF structure. If the directory does not exist, it will be created." +) +def init( + path: Annotated[ + str, typer.Argument(help="Directory in which to initialize a CTF") + ] = CTF_ROOT_DIRECTORY, + force: Annotated[ + bool, + typer.Option( + "--force", help="Overwrite the directory if it's already initialized" + ), + ] = False, +) -> None: created_directory = False + created_assets: list[str] = [] try: - if not os.path.isdir(args.path): - os.mkdir(args.path) - LOG.info(f'Creating directory "{args.path}"') + if not os.path.isdir(path): + os.mkdir(path) + LOG.info(f'Creating directory "{path}"') created_directory = True elif ( - os.path.isdir(os.path.join(args.path, "challenges")) - or os.path.isdir(os.path.join(args.path, ".deploy")) - ) and not args.force: + os.path.isdir(os.path.join(path, "challenges")) + or os.path.isdir(os.path.join(path, ".deploy")) + ) and not force: LOG.error( - f'Directory "{args.path}" is already initialized. Use --force to overwrite.' + f'Directory "{path}" is already initialized. Use --force to overwrite.' ) - LOG.error(args.force) + LOG.error(force) exit(code=1) - created_assets: list[str] = [] - for asset in os.listdir(p := os.path.join(TEMPLATES_ROOT_DIRECTORY, "init")): - dst_asset = os.path.join(args.path, asset) + dst_asset = os.path.join(path, asset) if os.path.isdir(src_asset := os.path.join(p, asset)): shutil.copytree(src_asset, dst_asset, dirs_exist_ok=True) LOG.info(f'Created "{dst_asset}" folder') @@ -126,8 +141,8 @@ def init(args: argparse.Namespace) -> None: import traceback if created_directory: - shutil.rmtree(args.path) - LOG.info(f'Removed created "{args.path}" folder') + shutil.rmtree(path) + LOG.info(f'Removed created "{path}" folder') else: for asset in created_assets: if os.path.isdir(asset): @@ -140,9 +155,29 @@ def init(args: argparse.Namespace) -> None: LOG.critical(traceback.format_exc()) -def new(args: argparse.Namespace) -> None: - LOG.info(msg=f"Creating a new track: {args.name}") - if not re.match(pattern=r"^[a-z][a-z0-9\-]{0,61}[a-z0-9]$", string=args.name): +@app.command(help="Create a new CTF track with a given name") +def new( + name: Annotated[ + str, + typer.Option( + help="Track name. No space, use underscores if needed.", + prompt="Track name. No space, use underscores if needed.", + ), + ], + template: Annotated[ + Template, + typer.Option("--template", "-t", help="Template to use for the track."), + ] = Template.APACHE_PHP, + force: Annotated[ + bool, + typer.Option( + "--force", + help="If directory already exists, delete it and create it again.", + ), + ] = False, +) -> None: + LOG.info(msg=f"Creating a new track: {name}") + if not re.match(pattern=r"^[a-z][a-z0-9\-]{0,61}[a-z0-9]$", string=name): LOG.critical( msg="""The track name Valid instance names must fulfill the following requirements: * The name must be between 1 and 63 characters long; @@ -155,11 +190,11 @@ def new(args: argparse.Namespace) -> None: if os.path.exists( path=( new_challenge_directory := os.path.join( - CTF_ROOT_DIRECTORY, "challenges", args.name + CTF_ROOT_DIRECTORY, "challenges", name ) ) ): - if args.force: + if force: LOG.debug(msg=f"Deleting {new_challenge_directory}") shutil.rmtree(new_challenge_directory) else: @@ -201,10 +236,10 @@ def new(args: argparse.Namespace) -> None: track_template = env.get_template(name="track.yaml.j2") render = track_template.render( data={ - "name": args.name, + "name": name, "full_ipv6_address": full_ipv6_address, "ipv6_subnet": ipv6_subnet, - "template": args.template.value, + "template": template.value, } ) with open( @@ -223,9 +258,9 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Directory {posts_directory} created.") track_template = env.get_template(name="topic.yaml.j2") - render = track_template.render(data={"name": args.name}) + render = track_template.render(data={"name": name}) with open( - file=(p := os.path.join(posts_directory, f"{args.name}.yaml")), + file=(p := os.path.join(posts_directory, f"{name}.yaml")), mode="w", encoding="utf-8", ) as f: @@ -234,9 +269,9 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Wrote {p}.") track_template = env.get_template(name="post.yaml.j2") - render = track_template.render(data={"name": args.name}) + render = track_template.render(data={"name": name}) with open( - file=(p := os.path.join(posts_directory, f"{args.name}_flag1.yaml")), + file=(p := os.path.join(posts_directory, f"{name}_flag1.yaml")), mode="w", encoding="utf-8", ) as f: @@ -244,7 +279,7 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Wrote {p}.") - if args.template == Template.TRACK_YAML_ONLY: + if template == Template.TRACK_YAML_ONLY: return files_directory = os.path.join(new_challenge_directory, "files") @@ -253,7 +288,7 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Directory {files_directory} created.") - if args.template == Template.FILES_ONLY: + if template == Template.FILES_ONLY: return terraform_directory = os.path.join(new_challenge_directory, "terraform") @@ -266,7 +301,7 @@ def new(args: argparse.Namespace) -> None: render = track_template.render( data={ - "name": args.name, + "name": name, "hardware_address": hardware_address, "ipv6": ipv6_address, "ipv6_subnet": ipv6_subnet, @@ -306,8 +341,8 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Directory {ansible_directory} created.") - track_template = env.get_template(name=f"deploy-{args.template}.yaml.j2") - render = track_template.render(data={"name": args.name}) + track_template = env.get_template(name=f"deploy-{template}.yaml.j2") + render = track_template.render(data={"name": name}) with open( file=(p := os.path.join(ansible_directory, "deploy.yaml")), mode="w", @@ -318,7 +353,7 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Wrote {p}.") track_template = env.get_template(name="inventory.j2") - render = track_template.render(data={"name": args.name}) + render = track_template.render(data={"name": name}) with open( file=(p := os.path.join(ansible_directory, "inventory")), mode="w", @@ -334,9 +369,9 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Directory {ansible_challenge_directory} created.") - if args.template == Template.APACHE_PHP: + if template == Template.APACHE_PHP: track_template = env.get_template(name="index.php.j2") - render = track_template.render(data={"name": args.name}) + render = track_template.render(data={"name": name}) with open( file=(p := os.path.join(ansible_challenge_directory, "index.php")), mode="w", @@ -346,9 +381,9 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Wrote {p}.") - if args.template == Template.PYTHON_SERVICE: + if template == Template.PYTHON_SERVICE: track_template = env.get_template(name="app.py.j2") - render = track_template.render(data={"name": args.name}) + render = track_template.render(data={"name": name}) with open( file=(p := os.path.join(ansible_challenge_directory, "app.py")), mode="w", @@ -363,11 +398,11 @@ def new(args: argparse.Namespace) -> None: mode="w", encoding="utf-8", ) as f: - f.write(f"{{{{ track_flags.{args.name}_flag_1 }}}} (1/2)\n") + f.write(f"{{{{ track_flags.{name}_flag_1 }}}} (1/2)\n") LOG.debug(msg=f"Wrote {p}.") - if args.template == Template.RUST_WEBSERVICE: + if template == Template.RUST_WEBSERVICE: # Copy the entire challenge template shutil.copytree( os.path.join(TEMPLATES_ROOT_DIRECTORY, "rust-webservice"), @@ -377,7 +412,7 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Wrote files to {ansible_challenge_directory}") manifest_template = env.get_template(name="Cargo.toml.j2") - render = manifest_template.render(data={"name": args.name}) + render = manifest_template.render(data={"name": name}) with open( file=(p := os.path.join(ansible_challenge_directory, "Cargo.toml")), mode="w", @@ -388,7 +423,37 @@ def new(args: argparse.Namespace) -> None: LOG.debug(msg=f"Wrote {p}.") -def destroy(args: argparse.Namespace) -> None: +@app.command( + help="Destroy everything deployed by Terraform. This is a destructive operation." +) +def destroy( + tracks: Annotated[ + list[str], + typer.Option( + "--tracks", + "-t", + help="Only destroy the given tracks (use the directory name)", + ), + ] = [], + production: Annotated[ + bool, + typer.Option( + "--production", + help="Do a production deployment. Only use this if you know what you're doing.", + ), + ] = False, + remote: Annotated[ + str, typer.Option("--remote", help="Incus remote to deploy to") + ] = "local", + force: Annotated[ + bool, + typer.Option( + "--force", + help="If there are artefacts remaining, delete them without asking.", + ), + ] = False, +) -> None: + ENV["INCUS_REMOTE"] = remote LOG.info(msg="tofu destroy...") if not os.path.exists( @@ -397,7 +462,7 @@ def destroy(args: argparse.Namespace) -> None: LOG.critical(msg="Nothing to destroy.") exit(code=1) - tracks = get_terraform_tracks_from_modules() + terraform_tracks = get_terraform_tracks_from_modules() r = ( subprocess.run( @@ -410,14 +475,14 @@ def destroy(args: argparse.Namespace) -> None: .strip() ) - args.tracks = set(args.tracks) - if args.tracks and args.tracks != tracks: - tracks &= args.tracks - if not tracks: + tmp_tracks = set(tracks) + if tmp_tracks and tmp_tracks != terraform_tracks: + terraform_tracks &= tmp_tracks + if not terraform_tracks: LOG.warning("No track to destroy.") return - if r in tracks: + if r in terraform_tracks: projects = { project["name"] for project in json.loads( @@ -430,7 +495,7 @@ def destroy(args: argparse.Namespace) -> None: ) } - projects = list((projects - tracks)) + projects = list((projects - terraform_tracks)) if len(projects) == 0: LOG.critical( msg="No project to switch to. This should never happen as the default should always exists." @@ -452,7 +517,7 @@ def destroy(args: argparse.Namespace) -> None: terraform_binary(), "destroy", "-auto-approve", - *[f"-target=module.track-{track}" for track in tracks], + *[f"-target=module.track-{track}" for track in terraform_tracks], ], cwd=os.path.join(CTF_ROOT_DIRECTORY, ".deploy"), check=False, @@ -494,11 +559,11 @@ def destroy(args: argparse.Namespace) -> None: ) ] - for module in tracks: + for module in terraform_tracks: if module in projects: LOG.warning(msg=f"The project {module} was not destroyed properly.") if ( - args.force + force or (input("Do you want to destroy it? [Y/n] ").lower() or "y") == "y" ): subprocess.run( @@ -512,7 +577,7 @@ def destroy(args: argparse.Namespace) -> None: if (tmp_module := module[0:15]) in networks: LOG.warning(msg=f"The network {tmp_module} was not destroyed properly.") if ( - args.force + force or (input("Do you want to destroy it? [Y/n] ").lower() or "y") == "y" ): subprocess.run( @@ -527,7 +592,7 @@ def destroy(args: argparse.Namespace) -> None: ) in network_acls: LOG.warning(msg=f"The network ACL {tmp_module} was not destroyed properly.") if ( - args.force + force or (input("Do you want to destroy it? [Y/n] ").lower() or "y") == "y" ): subprocess.run( @@ -537,28 +602,43 @@ def destroy(args: argparse.Namespace) -> None: env=ENV, ) remove_tracks_from_terraform_modules( - tracks=tracks, - remote=args.remote, - production="production" not in args or args.production, + tracks=terraform_tracks, + remote=remote, + production=production, ) LOG.info(msg="Successfully destroyed every track") -def flags(args: argparse.Namespace) -> None: - tracks = set() +@app.command(help="Get flags from tracks") +def flags( + tracks: Annotated[ + list[str], + typer.Option( + "--tracks", + "-t", + help="Only flags from the given tracks (use the directory name)", + ), + ] = [], + format: Annotated[ + OutputFormat, + typer.Option("--format", help="Output format", prompt="Output format"), + ] = OutputFormat.JSON, +) -> None: + distinct_tracks: set[str] = 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) + if not tracks: + distinct_tracks.add(entry) + elif entry in tracks: + distinct_tracks.add(entry) flags = [] - for track in tracks: + for track in distinct_tracks: LOG.debug(msg=f"Parsing track.yaml for track {track}") track_yaml = parse_track_yaml(track_name=track) @@ -572,32 +652,42 @@ def flags(args: argparse.Namespace) -> None: LOG.warning(msg="No flag found...") return - if args.format == OutputFormat.JSON: + if format == OutputFormat.JSON: print(json.dumps(obj=flags, indent=2)) - elif args.format == OutputFormat.CSV: + elif format == OutputFormat.CSV: output = io.StringIO() writer = csv.DictWriter(f=output, fieldnames=flags[0].keys()) writer.writeheader() writer.writerows(rowdicts=flags) print(output.getvalue()) - elif args.format == OutputFormat.YAML: + elif format == OutputFormat.YAML: print(yaml.safe_dump(data=flags)) -def services(args: argparse.Namespace) -> None: - tracks = set() +@app.command(help="Get services from tracks") +def services( + tracks: Annotated[ + list[str], + typer.Option( + "--tracks", + "-t", + help="Only services from the given tracks (use the directory name)", + ), + ] = [], +) -> None: + distinct_tracks: set[str] = 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) + if not tracks: + distinct_tracks.add(entry) + elif entry in tracks: + distinct_tracks.add(entry) - for track in tracks: + for track in distinct_tracks: LOG.debug(msg=f"Parsing track.yaml for track {track}") track_yaml = parse_track_yaml(track_name=track) @@ -616,27 +706,49 @@ def services(args: argparse.Namespace) -> None: print(f"{track}/{instance}/{name} {contact} {address} {check} {port}") -def generate(args: argparse.Namespace) -> set[str]: +@app.command( + help="Generate the deployment files using `terraform init` and `terraform validate`" +) +def generate( + tracks: Annotated[ + list[str], + typer.Option( + "--tracks", + "-t", + help="Only generate the given tracks (use the directory name)", + ), + ] = [], + production: Annotated[ + bool, + typer.Option( + "--production", + help="Do a production deployment. Only use this if you know what you're doing.", + ), + ] = False, + remote: Annotated[ + str, typer.Option("--remote", help="Incus remote to deploy to") + ] = "local", +) -> set[str]: + ENV["INCUS_REMOTE"] = remote # Get the list of tracks. - tracks = set( + distinct_tracks = set( track for track in get_all_available_tracks() if validate_track_can_be_deployed(track=track) - and (not args.tracks or track in args.tracks) + and (not tracks or track in tracks) ) - LOG.debug(msg=f"Found {len(tracks)} tracks") - - if tracks: + if distinct_tracks: + LOG.debug(msg=f"Found {len(distinct_tracks)} tracks") # Generate the Terraform modules file. - create_terraform_modules_file(remote=args.remote, production=args.production) + create_terraform_modules_file(remote=remote, production=production) add_tracks_to_terraform_modules( - tracks=tracks, - remote=args.remote, - production=args.production, + tracks=distinct_tracks, + remote=remote, + production=production, ) - for track in tracks: + for track in distinct_tracks: relpath = os.path.relpath( os.path.join(CTF_ROOT_DIRECTORY, ".deploy", "common"), ( @@ -689,26 +801,57 @@ def generate(args: argparse.Namespace) -> set[str]: cwd=os.path.join(CTF_ROOT_DIRECTORY, ".deploy"), check=True, ) + else: + LOG.critical("No track was found") + exit(code=1) - return tracks + return distinct_tracks -def deploy(args): - if args.func.__name__ == "redeploy": - tracks = set( +@app.command(help="Deploy and provision the tracks") +def deploy( + tracks: Annotated[ + list[str], + typer.Option( + "--tracks", + "-t", + help="Only deploy the given tracks (use the directory name)", + ), + ] = [], + production: Annotated[ + bool, + typer.Option( + "--production", + help="Do a production deployment. Only use this if you know what you're doing.", + ), + ] = False, + remote: Annotated[ + str, typer.Option("--remote", help="Incus remote to deploy to") + ] = "local", + redeploy: Annotated[ + bool, typer.Option("--redeploy", help="Do not use. Use `ctf redeploy` instead.") + ] = False, + force: Annotated[ + bool, + typer.Option("--force", help="Force the deployment even if there are errors."), + ] = False, +): + ENV["INCUS_REMOTE"] = remote + if redeploy: + distinct_tracks = set( track for track in get_all_available_tracks() - if validate_track_can_be_deployed(track=track) and track in args.tracks + if validate_track_can_be_deployed(track=track) and track in tracks ) add_tracks_to_terraform_modules( - tracks=tracks - get_terraform_tracks_from_modules(), - remote=args.remote, - production=args.production, + tracks=distinct_tracks - get_terraform_tracks_from_modules(), + remote=remote, + production=production, ) else: # Run generate first. - tracks = generate(args=args) + distinct_tracks = generate(tracks=tracks, production=production, remote=remote) # Check if Git LFS is installed on the system as it is required for deployment. if not check_git_lfs(): @@ -724,7 +867,7 @@ def deploy(args): "git", "lfs", "pull", - f"--include={','.join([os.path.join('challenges', track, 'ansible', '*') for track in tracks])}", + f"--include={','.join([os.path.join('challenges', track, 'ansible', '*') for track in distinct_tracks])}", ], check=True, ) @@ -743,8 +886,8 @@ def deploy(args): if (input("Do you want to clean and start over? [Y/n] ").lower() or "y") != "y": exit(code=1) - args.force = True - destroy(args=args) + force = True + destroy(tracks=tracks, production=production, remote=remote, force=force) subprocess.run( args=[terraform_binary(), "apply", "-auto-approve"], @@ -755,11 +898,11 @@ def deploy(args): LOG.warning( "CTRL+C was detected during Terraform deployment. Destroying everything..." ) - args.force = True - destroy(args=args) + force = True + destroy(tracks=tracks, production=production, remote=remote, force=force) exit(code=0) - for track in tracks: + for track in distinct_tracks: if not os.path.exists( path=( path := os.path.join(CTF_ROOT_DIRECTORY, "challenges", track, "ansible") @@ -767,9 +910,11 @@ def deploy(args): ): continue - run_ansible_playbook(args=args, track=track, path=path) + run_ansible_playbook( + remote=remote, production=production, track=track, path=path + ) - if not args.production: + if not production: incus_list = json.loads( s=subprocess.run( args=["incus", "list", f"--project={track}", "--format", "json"], @@ -788,7 +933,7 @@ def deploy(args): LOG.debug(msg=f"Mapping: {ipv6_to_container_name}") - if args.remote == "local": + if remote == "local": LOG.debug(msg=f"Parsing track.yaml for track {track}") track_yaml = parse_track_yaml(track_name=track) @@ -827,25 +972,28 @@ def deploy(args): args=["incus", f"--project={track}", "list"], check=True, env=ENV ) - if not args.production and args.tracks: - args.tracks = list(args.tracks) + if not production and distinct_tracks: + tracks_list = list(distinct_tracks) track_index = input( - f"""Do you want to `incus project switch` to any of the tracks mentioned in argument? -{chr(10).join([f"{list(args.tracks).index(t) + 1}) {t}" for t in args.tracks])} + textwrap.dedent( + f"""\ + Do you want to `incus project switch` to any of the tracks mentioned in argument? + {chr(10).join([f"{list(tracks_list).index(t) + 1}) {t}" for t in tracks_list])} -Which? """ + Which? """ + ) ) if ( track_index.isnumeric() and (track_index := int(track_index)) - and 0 < track_index <= len(args.tracks) + and 0 < track_index <= len(tracks_list) ): LOG.info( - msg=f"Running `incus project switch {args.tracks[track_index - 1]}`" + msg=f"Running `incus project switch {tracks_list[track_index - 1]}`" ) subprocess.run( - args=["incus", "project", "switch", args.tracks[track_index - 1]], + args=["incus", "project", "switch", tracks_list[track_index - 1]], check=True, env=ENV, ) @@ -855,12 +1003,12 @@ def deploy(args): ) -def run_ansible_playbook(args: argparse.Namespace, track: str, path: str) -> None: +def run_ansible_playbook(remote: str, production: bool, track: str, path: str) -> None: extra_args = [] - if "remote" in args and args.remote: - extra_args += ["-e", f"ansible_incus_remote={args.remote}"] + if remote: + extra_args += ["-e", f"ansible_incus_remote={remote}"] - if args.production: + if production: extra_args += ["-e", "nsec_production=true"] LOG.info(msg=f"Running common yaml with ansible for track {track}...") @@ -894,14 +1042,65 @@ def run_ansible_playbook(args: argparse.Namespace, track: str, path: str) -> Non shutil.rmtree(artifacts_path) -def redeploy(args: argparse.Namespace) -> None: - destroy(args=args) - deploy(args=args) +@app.command(help="Destroy and then deploy the given tracks") +def redeploy( + tracks: Annotated[ + list[str], + typer.Option( + "--tracks", + "-t", + help="Only redeploy the given tracks (use the directory name)", + ), + ] = [], + production: Annotated[ + bool, + typer.Option( + "--production", + help="Do a production deployment. Only use this if you know what you're doing.", + ), + ] = False, + remote: Annotated[ + str, typer.Option("--remote", help="Incus remote to deploy to") + ] = "local", + force: Annotated[ + bool, + typer.Option( + "--force", + help="If there are artefacts remaining, delete them without asking.", + ), + ] = False, +) -> None: + ENV["INCUS_REMOTE"] = remote + destroy(tracks=tracks, production=production, remote=remote, force=force) + deploy( + tracks=tracks, production=production, remote=remote, force=force, redeploy=True + ) -def check(args: argparse.Namespace) -> None: +@app.command(help="Preview the changes") +def check( + tracks: Annotated[ + list[str], + typer.Option( + "--tracks", + "-t", + help="Only check the given tracks (use the directory name)", + ), + ] = [], + production: Annotated[ + bool, + typer.Option( + "--production", + help="Do a production deployment. Only use this if you know what you're doing.", + ), + ] = False, + remote: Annotated[ + str, typer.Option("--remote", help="Incus remote to deploy to") + ] = "local", +) -> None: + ENV["INCUS_REMOTE"] = remote # Run generate first. - generate(args=args) + generate(tracks=tracks, production=production, remote=remote) # Then run terraform plan. subprocess.run( @@ -911,28 +1110,61 @@ def check(args: argparse.Namespace) -> None: ) # Check if Git LFS is installed on the system as it will be required for deployment. - if args.func.__name__ == "check" and not check_git_lfs(): + if not check_git_lfs(): LOG.warning( msg="Git LFS is missing from your system. Install it before deploying." ) -def stats(args: argparse.Namespace) -> None: +@app.command( + help="Generate statistics (such as number of tracks, number of flags, total flag value, etc.) from all the `track.yaml files. Outputs as JSON." +) +def stats( + tracks: Annotated[ + list[str], + typer.Option( + "--tracks", + "-t", + help="Name of the tracks to count in statistics (if not specified, all tracks are counted).", + ), + ] = [], + generate_badges: Annotated[ + bool, + typer.Option( + "--generate-badges", + help="Generate SVG files of some statistics in the .badges directory.", + ), + ] = False, + charts: Annotated[ + bool, + typer.Option( + "--charts", + help="Generate PNG charts of some statistics in the .charts directory.", + ), + ] = False, + historical: Annotated[ + bool, + typer.Option( + "--historical", + 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.", + ), + ] = False, +) -> None: LOG.debug(msg="Generating statistics...") stats = {} - tracks = [] + distinct_tracks: set[str] = set() for entry in os.listdir( (challenges_directory := os.path.join(CTF_ROOT_DIRECTORY, "challenges")) ): if os.path.isdir( (track_directory := os.path.join(challenges_directory, entry)) ) and os.path.isfile(os.path.join(track_directory, "track.yaml")): - if not args.tracks: - tracks.append(entry) - elif entry in args.tracks: - tracks.append(entry) + if not tracks: + distinct_tracks.add(entry) + elif entry in tracks: + distinct_tracks.add(entry) - stats["number_of_tracks"] = len(tracks) + stats["number_of_tracks"] = len(distinct_tracks) stats["number_of_tracks_integrated_with_scenario"] = 0 stats["number_of_flags"] = 0 stats["highest_value_flag"] = 0 @@ -950,7 +1182,7 @@ def stats(args: argparse.Namespace) -> None: stats["not_integrated_with_scenario"] = [] challenge_designers = set() flags = [] - for track in tracks: + for track in distinct_tracks: track_yaml = parse_track_yaml(track_name=track) number_of_flags = len(track_yaml["flags"]) stats["number_of_flags_per_track"][track] = number_of_flags @@ -1007,7 +1239,7 @@ def stats(args: argparse.Namespace) -> None: ) print(json.dumps(stats, indent=2, ensure_ascii=False)) - if args.generate_badges: + if generate_badges: if not _has_pybadges: LOG.critical(msg="Module pybadges was not found.") exit(code=1) @@ -1059,7 +1291,7 @@ def stats(args: argparse.Namespace) -> None: ), ) - if args.charts: + if charts: if not _has_matplotlib: LOG.critical(msg="Module matplotlib was not found.") exit(code=1) @@ -1109,7 +1341,7 @@ def stats(args: argparse.Namespace) -> None: plt.savefig(os.path.join(".charts", "points_per_track.png")) plt.clf() - if args.historical: + if historical: # Number of points and flags over time historical_data = {} commit_list = ( @@ -1187,15 +1419,20 @@ def stats(args: argparse.Namespace) -> None: LOG.debug(msg="Done...") -def list_tracks(args: argparse.Namespace) -> None: - tracks = [] +@app.command("list", help="List tracks and their author(s).") +def list_tracks( + format: Annotated[ + ListOutputFormat, typer.Option("--format", "-f", help="Output format") + ] = ListOutputFormat.PRETTY, +) -> None: + tracks: set[str] = set() for track in os.listdir(path=os.path.join(CTF_ROOT_DIRECTORY, "challenges")): if os.path.isdir( s=os.path.join(CTF_ROOT_DIRECTORY, "challenges", track) ) and os.path.exists( path=os.path.join(CTF_ROOT_DIRECTORY, "challenges", track, "track.yaml") ): - tracks.append(track) + tracks.add(track) parsed_tracks = [] for track in tracks: @@ -1217,7 +1454,7 @@ def list_tracks(args: argparse.Namespace) -> None: ] ) - if args.format == "pretty": + if format.value == "pretty": LOG.info( "\n" + tabulate( @@ -1233,10 +1470,13 @@ def list_tracks(args: argparse.Namespace) -> None: ) ) else: - raise ValueError(f"Invalid format: {args.format}") + raise ValueError(f"Invalid format: {format.value}") -def validate(args: argparse.Namespace) -> None: +@app.command( + help="Run many static validations to ensure coherence and quality in the tracks and repo as a whole." +) +def validate() -> None: LOG.info(msg="Starting ctf validate...") LOG.info(msg=f"Found {len(validators_list)} Validators") @@ -1343,294 +1583,23 @@ def write_badge(name: str, svg: str) -> None: f.write(svg) -def main(): - # Command line parsing. - parser = argparse.ArgumentParser( - prog="ctf", - description="CTF preparation tool. Run from the root CTF repo directory or set the CTF_ROOT_DIR environment variable to run the tool.", - ) - - subparsers = parser.add_subparsers(required=True) - - # Create a fake subparser as the version is printed before anything else in the __init__.py file. - subparsers.add_parser( - "version", - help="Script version.", - ) - - parser_init = subparsers.add_parser( - "init", - help="Initialize a folder with the default CTF structure.", - ) - parser_init.set_defaults(func=init) - parser_init.add_argument( - "path", - nargs="?", - default=CTF_ROOT_DIRECTORY, - help="Initialize the folder at the given path.", - ) - parser_init.add_argument( - "--force", - "-f", - action="store_true", - default=False, - help="Overwrite the directory if it's already initialized.", - ) - - parser_flags = subparsers.add_parser( - "flags", - help="Get flags from tracks", - ) - parser_flags.set_defaults(func=flags) - parser_flags.add_argument( - "--tracks", - "-t", - nargs="+", - default=[], - help="Only flags from the given tracks (use the folder name)", - ) - parser_flags.add_argument( - "--format", - help="Output format.", - choices=list(OutputFormat), - default=OutputFormat.JSON, - 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`", - ) - parser_generate.set_defaults(func=generate) - parser_generate.add_argument( - "--tracks", - "-t", - nargs="+", - default=[], - help="Only generate the given tracks (use the folder name)", - ) - parser_generate.add_argument( - "--production", - action="store_true", - default=False, - help="Do a production deployment. Only use this if you know what you're doing.", - ) - parser_generate.add_argument( - "--remote", - default="local", - help="Incus remote to deploy to.", - type=str, - choices=AVAILABLE_INCUS_REMOTES, - ) - - parser_redeploy = subparsers.add_parser( - "redeploy", help="Destroy and deploy all the changes" - ) - parser_redeploy.set_defaults(func=redeploy) - parser_redeploy.add_argument( - "--tracks", - "-t", - nargs="+", - default=[], - help="Only redeploy the given tracks (use the folder name)", - ) - parser_redeploy.add_argument( - "--production", - action="store_true", - default=False, - help="Do a production deployment. Only use this if you know what you're doing.", - ) - parser_redeploy.add_argument( - "--remote", - default="local", - help="Incus remote to redeploy to.", - type=str, - choices=AVAILABLE_INCUS_REMOTES, - ) - parser_redeploy.add_argument( - "--force", - help="If there are artefacts remaining, delete them without asking.", - action="store_true", - default=False, - ) - - parser_deploy = subparsers.add_parser("deploy", help="Deploy all the changes") - parser_deploy.set_defaults(func=deploy) - parser_deploy.add_argument( - "--tracks", - "-t", - nargs="+", - default=[], - help="Only deploy the given tracks (use the folder name)", - ) - parser_deploy.add_argument( - "--production", - action="store_true", - default=False, - help="Do a production deployment. Only use this if you know what you're doing.", - ) - parser_deploy.add_argument( - "--remote", - default="local", - help="Incus remote to deploy to.", - type=str, - choices=AVAILABLE_INCUS_REMOTES, - ) +@app.command(help="Print the tool's version.") +def version(): + # Create an empty command as the version is printed before anything else in the __init__.py file. + pass - parser_check = subparsers.add_parser("check", help="Preview the changes") - parser_check.set_defaults(func=check) - parser_check.add_argument( - "--tracks", - "-t", - nargs="+", - default=[], - help="Only check the given tracks (use the folder name)", - ) - parser_check.add_argument( - "--production", - action="store_true", - default=False, - help="Do a production deployment. Only use this if you know what you're doing.", - ) - parser_check.add_argument( - "--remote", - default="local", - help="Incus remote to deploy to.", - type=str, - choices=AVAILABLE_INCUS_REMOTES, - ) - parser_new = subparsers.add_parser("new", help="Create a new track.") - parser_new.set_defaults(func=new) - parser_new.add_argument( - "--name", help="Track name. No space, use underscores if needed.", required=True - ) - parser_new.add_argument( - "--template", - help="Template name.", - choices=list(Template), - default=Template.APACHE_PHP, - type=Template, - ) - parser_new.add_argument( - "--force", - help="If directory already exists, delete it and create it again.", - action="store_true", - default=False, - ) - - parser_destroy = subparsers.add_parser( - "destroy", - help="Destroy everything deployed by Terraform. This is a destructive operation.", - ) - parser_destroy.set_defaults(func=destroy) - parser_destroy.add_argument( - "--force", - help="If there are artefacts remaining, delete them without asking.", - action="store_true", - default=False, - ) - parser_destroy.add_argument( - "--remote", - default="local", - help="Incus remote to destroy from.", - type=str, - choices=AVAILABLE_INCUS_REMOTES, - ) - parser_destroy.add_argument( - "--tracks", - "-t", - nargs="+", - default=[], - help="Only destroy the given tracks (use the folder name)", - ) - - parser_stats = subparsers.add_parser( - "stats", - help="Generate statistics (such as number of tracks, number of flags, total flag value, etc.) from all the `track.yaml files. Outputs as JSON.", - ) - parser_stats.set_defaults(func=stats) - parser_stats.add_argument( - "--tracks", - "-t", - nargs="+", - default=[], - help="Name of the tracks to count in statistics (if not specified, all tracks are counted).", - ) - parser_stats.add_argument( - "--generate-badges", - action="store_true", - 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", - help="Run many static validations to ensure coherence and quality in the tracks and repo as a whole.", - ) - parser_validate.set_defaults(func=validate) - parser_validate.add_argument( - "--list", - "-l", - action="store_true", - default=False, - help="List validators.", - ) - - parser_list = subparsers.add_parser( - "list", - help="List tracks and their author(s).", - ) - parser_list.set_defaults(func=list_tracks) - parser_list.add_argument( - "--format", - "-f", - choices=["pretty"], - default="pretty", - help="Output format.", - ) - - argcomplete.autocomplete(parser) - - args = parser.parse_args() +def main(): + app() - if "remote" in args and args.remote: - ENV["INCUS_REMOTE"] = args.remote +if __name__ == "__main__": if not os.path.isdir(s=(p := os.path.join(CTF_ROOT_DIRECTORY, "challenges"))): - if args.func.__name__ != "init": + import sys + + if "init" not in sys.argv: LOG.error( msg=f"Directory `{p}` not found. Make sure this script is ran from the root directory OR set the CTF_ROOT_DIR environment variable to the root directory." ) exit(code=1) - - args.func(args=args) - - -if __name__ == "__main__": main() diff --git a/ctf/templates/track.yaml.j2 b/ctf/templates/track.yaml.j2 index c1614b9..3e42155 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 Eric Boivin has fully integrated the track with the scenario, which implies he wrote the discourse posts. +# Set to true when the person responsible of the theme has fully integrated the track with the scenario, which implies he wrote the discourse posts. integrated_with_scenario: false contacts: diff --git a/poetry.lock b/poetry.lock index 92e9e47..dd976d4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,19 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. - -[[package]] -name = "argcomplete" -version = "3.6.2" -description = "Bash tab completion for argparse" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591"}, - {file = "argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf"}, -] - -[package.extras] -test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "attrs" @@ -582,6 +567,31 @@ files = [ {file = "kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e"}, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "markupsafe" version = "3.0.2" @@ -733,6 +743,18 @@ python-dateutil = ">=2.7" [package.extras] dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -999,6 +1021,21 @@ requests = ">=2.22.0,<3" dev = ["Flask (>=2.0)", "Pillow (>=5)", "fonttools (>=3.26)", "nox", "pytest (>=3.6)", "xmldiff (>=2.4)"] pil-measurement = ["Pillow (>=6,<10)"] +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyparsing" version = "3.2.3" @@ -1150,6 +1187,25 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rich" +version = "14.1.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, + {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rpds-py" version = "0.26.0" @@ -1325,6 +1381,18 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "six" version = "1.17.0" @@ -1409,6 +1477,24 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +[[package]] +name = "typer" +version = "0.16.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, + {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "typing-extensions" version = "4.14.1" @@ -1416,7 +1502,6 @@ description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version < \"3.13\"" files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, @@ -1447,4 +1532,4 @@ workflow = ["matplotlib", "pybadges", "standard-imghdr"] [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "79cc95e660269b7ec7fe53e72161433361cbb29dc5e6e037a29f262425d57317" +content-hash = "fc4325b8b3bbc432ae7b3106776d581d52e69815890691fdabcf5abf9d83f674" diff --git a/pyproject.toml b/pyproject.toml index 91d6047..fd2ad25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,18 +7,18 @@ name = "ctf-script" authors = [{ name = "NorthSec Challenge Designers", email = "info@nsec.io" }] description = "NorthSec CTF challenges" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ + "black", + "coloredlogs==15.0.1", "jinja2==3.1.5", - "pyyaml<7", "jsonschema==4.23.0", - "coloredlogs==15.0.1", + "pyyaml<7", "setuptools", - "argcomplete", - "black", "tabulate==0.9.0", + "typer==0.16.0", ] -version = "1.3.0" +version = "2.0.0" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent",