Skip to content

Commit 93f8cae

Browse files
author
Miłosz Skaza
authored
add challenge mirror, verify, format functionality (#134)
Adds `ctf challenge mirror <challenge>` and `ctf challenge verify <challenge>` adapted from #106 Originally, this functionality was called `pull` and `verify` - however, `push` is already used to push challenge changes to the git repository. I think `mirror` is a better name, as ctfcli will attempt to mirror / copy the remote state from ctfd. This way `pull` stays in its current git-like form, for git-related operations. More additions: - I've removed update / create / verify files - this can be achieved by just using --ignore=files. - I've added `files_directory_name` (defaulting to `dist`) to specify where ctfcli should download the files, relative to challenge.yml - I've added a warning when there are additional challenges on the remote, that are not registered locally - `ctf challenge verify` will exit with status code 2 if the verification was successful, but some challenges are out of sync. - I've fixed some typos Thanks to @reteps for the initial contribution! Closes: #101 #106
1 parent d00925d commit 93f8cae

File tree

4 files changed

+982
-61
lines changed

4 files changed

+982
-61
lines changed

README.md

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,48 +20,55 @@ Alternatively, you can always install it with `pip` as a python module:
2020

2121
## 1. Create an Event
2222

23-
ctfcli turns the current folder into a CTF event git repo. It asks for the base url of the CTFd instance you're working with and an access token.
23+
Ctfcli turns the current folder into a CTF event git repo.
24+
It asks for the base url of the CTFd instance you're working with and an access token.
2425

2526
```
2627
❯ ctf init
2728
Please enter CTFd instance URL: https://demo.ctfd.io
2829
Please enter CTFd Admin Access Token: d41d8cd98f00b204e9800998ecf8427e
29-
Do you want to continue with https://demo.ctfd.io and d41d8cd98f00b204e9800998ecf8427e [y/N]: y
30+
Do you want to continue with https://demo.ctfd.io and d41d8cd98f00b204e9800998ecf8427e [Y/n]: y
3031
Initialized empty Git repository in /Users/user/Downloads/event/.git/
3132
```
3233

33-
This will create the `.ctf` folder with the `config` file that will specify the URL, access token, and keep a record of all the challenges dedicated for this event.
34+
This will create the `.ctf` folder with the `config` file that will specify the URL, access token, and keep a record of
35+
all the challenges dedicated for this event.
3436

3537
## 2. Add challenges
3638

37-
Events are made up of challenges. Challenges can be made from a subdirectory or pulled from another repository. Remote challenges are pulled into the event repo and a reference is kept in the `.ctf/config` file.
39+
Events are made up of challenges.
40+
Challenges can be made from a subdirectory or pulled from another repository.
41+
GIT-enabled challenges are pulled into the event repo, and a reference is kept in the `.ctf/config` file.
3842

3943
```
4044
❯ ctf challenge add [REPO | FOLDER]
4145
```
4246

47+
##### Local folder:
4348
```
4449
❯ ctf challenge add crypto/stuff
4550
```
4651

52+
##### GIT repository:
4753
```
4854
❯ ctf challenge add https://github.com/challenge.git
49-
challenge
5055
Cloning into 'challenge'...
51-
remote: Enumerating objects: 624, done.
52-
remote: Counting objects: 100% (624/624), done.
53-
remote: Compressing objects: 100% (540/540), done.
54-
remote: Total 624 (delta 109), reused 335 (delta 45), pack-reused 0
55-
Receiving objects: 100% (624/624), 6.49 MiB | 21.31 MiB/s, done.
56-
Resolving deltas: 100% (109/109), done.
56+
[...]
57+
```
58+
59+
##### GIT repository to a specific subfolder:
60+
```
61+
❯ ctf challenge add https://github.com/challenge.git crypto
62+
Cloning into 'crypto/challenge'...
63+
[...]
5764
```
5865

5966
## 3. Install challenges
6067

61-
Installing a challenge will automatically create the challenge in your CTFd instance using the API.
68+
Installing a challenge will create the challenge in your CTFd instance using the API.
6269

6370
```
64-
❯ ctf challenge install [challenge.yml | DIRECTORY]
71+
❯ ctf challenge install [challenge]
6572
```
6673

6774
```
@@ -72,12 +79,13 @@ Installing buffer_overflow
7279
Success!
7380
```
7481

75-
## 4. Update challenges
82+
## 4. Sync challenges
7683

77-
Syncing a challenge will automatically update the challenge in your CTFd instance using the API. Any changes made in the `challenge.yml` file will be reflected in your instance.
84+
Syncing a challenge will update the challenge in your CTFd instance using the API.
85+
Any changes made in the `challenge.yml` file will be reflected in your instance.
7886

7987
```
80-
❯ ctf challenge sync [challenge.yml | DIRECTORY]
88+
❯ ctf challenge sync [challenge]
8189
```
8290

8391
```
@@ -88,6 +96,70 @@ Syncing buffer_overflow
8896
Success!
8997
```
9098

99+
## 5. Deploy services
100+
101+
Deploying a challenge will automatically create the challenge service (by default in your CTFd instance).
102+
You can also use a different deployment handler to deploy the service via SSH to your own server,
103+
or a separate docker registry.
104+
105+
The challenge will also be automatically installed or synced.
106+
Obtained connection info will be added to your `challenge.yml` file.
107+
```
108+
❯ ctf challenge deploy [challenge]
109+
```
110+
111+
```
112+
❯ ctf challenge deploy web-1
113+
Deploying challenge service 'web-1' (web-1/challenge.yml) with CloudDeploymentHandler ...
114+
Challenge service deployed at: https://web-1-example-instance.chals.io
115+
Updating challenge 'web-1'
116+
Success!
117+
```
118+
119+
## 6. Verify challenges
120+
121+
Verifying a challenge will check if the local version of the challenge is the same as one installed in your CTFd instance.
122+
123+
```
124+
❯ ctf challenge verify [challenge]
125+
```
126+
127+
```
128+
❯ ctf challenge verify buffer_overflow
129+
Verifying challenges [------------------------------------] 0%
130+
Verifying challenges [####################################] 100%
131+
Success! All challenges verified!
132+
Challenges in sync:
133+
- buffer_overflow
134+
```
135+
136+
## 7. Mirror changes
137+
138+
Mirroring a challenge is the reverse operation to syncing.
139+
It will update the local version of the challenge with details of the one installed in your CTFd instance.
140+
It will also issue a warning if you have any remote challenges that are not tracked locally.
141+
142+
```
143+
❯ ctf challenge mirror [challenge]
144+
```
145+
146+
```
147+
❯ ctf challenge verify buffer_overflow
148+
Mirorring challenges [------------------------------------] 0%
149+
Mirorring challenges [####################################] 100%
150+
Success! All challenges mirrored!
151+
```
152+
153+
## Operations on all challenges
154+
155+
You can perform operations on all challenges defined in your config by simply skipping the challenge parameter.
156+
157+
- `ctf challenge install`
158+
- `ctf challenge sync`
159+
- `ctf challenge deploy`
160+
- `ctf challenge verify`
161+
- `ctf challenge mirror`
162+
91163
# Challenge Templates
92164

93165
`ctfcli` contains pre-made challenge templates to make it faster to create CTF challenges with safe defaults.
@@ -126,6 +198,6 @@ The specification format has already been tested and used with CTFd in productio
126198

127199
# Plugins
128200

129-
`ctfcli` plugins are essentially additions to to the command line interface via dynamic class modifications. See the [plugin documentation page](docs/plugins.md) for a simple example.
201+
`ctfcli` plugins are essentially additions to the command line interface via dynamic class modifications. See the [plugin documentation page](docs/plugins.md) for a simple example.
130202

131203
*`ctfcli` is an alpha project! The plugin interface is likely to change!*

ctfcli/cli/challenges.py

Lines changed: 195 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ def deploy(
693693
elif deployment_result.connection_info:
694694
challenge["connection_info"] = deployment_result.connection_info
695695

696-
# Finally if no connection_info was provided in the challenge and the
696+
# Finally, if no connection_info was provided in the challenge and the
697697
# deployment didn't result in one either, just ensure it's not present
698698
else:
699699
challenge["connection_info"] = None
@@ -714,6 +714,8 @@ def deploy(
714714
f"Challenge service deployed at: {challenge['connection_info']}",
715715
fg="green",
716716
)
717+
718+
challenge.save() # Save the challenge with the new connection_info
717719
else:
718720
click.secho(
719721
"Could not resolve a connection_info for the deployed service.\nIf your DeploymentHandler "
@@ -793,8 +795,8 @@ def lint(
793795
click.secho("Success! Lint didn't find any issues!", fg="green")
794796
return 0
795797

796-
def healthcheck(self, challenge: str = None):
797-
log.debug(f"lint: (challenge={challenge})")
798+
def healthcheck(self, challenge: str = None) -> int:
799+
log.debug(f"healthcheck: (challenge={challenge})")
798800
config = Config()
799801
challenge_path = Path.cwd()
800802

@@ -861,3 +863,193 @@ def healthcheck(self, challenge: str = None):
861863

862864
click.secho("Success! Challenge passed the healthcheck.", fg="green")
863865
return 0
866+
867+
def mirror(
868+
self,
869+
challenge: str = None,
870+
files_directory: str = "dist",
871+
skip_verify: bool = False,
872+
ignore: Union[str, Tuple[str]] = (),
873+
) -> int:
874+
config = Config()
875+
challenge_keys = [challenge]
876+
877+
# Get all local challenges if not specifying a challenge
878+
if challenge is None:
879+
challenge_keys = config.challenges.keys()
880+
881+
# Check if there are attributes to be ignored, and if there's only one cast it to a tuple
882+
if isinstance(ignore, str):
883+
ignore = (ignore,)
884+
885+
# Load local challenges
886+
local_challenges, failed_mirrors = [], []
887+
for challenge_key in challenge_keys:
888+
challenge_path = config.project_path / Path(challenge_key)
889+
890+
if not challenge_path.name.endswith(".yml"):
891+
challenge_path = challenge_path / "challenge.yml"
892+
893+
try:
894+
local_challenges.append(Challenge(challenge_path))
895+
896+
except ChallengeException as e:
897+
click.secho(str(e), fg="red")
898+
failed_mirrors.append(challenge_key)
899+
continue
900+
901+
remote_challenges = Challenge.load_installed_challenges()
902+
903+
if len(challenge_keys) > 1:
904+
# When mirroring all challenges - issue a warning if there are extra challenges on the remote
905+
# that do not have a local version
906+
local_challenge_names = [c["name"] for c in local_challenges]
907+
908+
for remote_challenge in remote_challenges:
909+
if remote_challenge["name"] not in local_challenge_names:
910+
click.secho(
911+
f"Found challenge '{remote_challenge['name']}' in CTFd, but not in .ctf/config\n"
912+
"Mirroring does not create new local challenges\n"
913+
"Please add the local challenge if you wish to manage it with ctfcli\n",
914+
fg="yellow",
915+
)
916+
917+
with click.progressbar(local_challenges, label="Mirroring challenges") as challenges:
918+
for challenge in challenges:
919+
try:
920+
if not skip_verify and challenge.verify(ignore=ignore):
921+
click.secho(
922+
f"Challenge '{challenge['name']}' is already in sync. Skipping mirroring.",
923+
fg="blue",
924+
)
925+
else:
926+
# if skip_verify is True or challenge.verify(ignore=ignore) is False
927+
challenge.mirror(files_directory_name=files_directory, ignore=ignore)
928+
929+
except ChallengeException as e:
930+
click.secho(str(e), fg="red")
931+
failed_mirrors.append(challenge["name"])
932+
933+
if len(failed_mirrors) == 0:
934+
click.secho("Success! All challenges mirrored!", fg="green")
935+
return 0
936+
937+
click.secho("Mirror failed for:", fg="red")
938+
for challenge in failed_mirrors:
939+
click.echo(f" - {challenge}")
940+
941+
return 1
942+
943+
def verify(self, challenge: str = None, ignore: Tuple[str] = ()) -> int:
944+
config = Config()
945+
challenge_keys = [challenge]
946+
947+
# Get all local challenges if not specifying a challenge
948+
if challenge is None:
949+
challenge_keys = config.challenges.keys()
950+
951+
# Check if there are attributes to be ignored, and if there's only one cast it to a tuple
952+
if isinstance(ignore, str):
953+
ignore = (ignore,)
954+
955+
# Load local challenges
956+
local_challenges, failed_verifications = [], []
957+
for challenge_key in challenge_keys:
958+
challenge_path = config.project_path / Path(challenge_key)
959+
960+
if not challenge_path.name.endswith(".yml"):
961+
challenge_path = challenge_path / "challenge.yml"
962+
963+
try:
964+
local_challenges.append(Challenge(challenge_path))
965+
966+
except ChallengeException as e:
967+
click.secho(str(e), fg="red")
968+
failed_verifications.append(challenge_key)
969+
continue
970+
971+
remote_challenges = Challenge.load_installed_challenges()
972+
973+
if len(challenge_keys) > 1:
974+
# When verifying all challenges - issue a warning if there are extra challenges on the remote
975+
# that do not have a local version
976+
local_challenge_names = [c["name"] for c in local_challenges]
977+
978+
for remote_challenge in remote_challenges:
979+
if remote_challenge["name"] not in local_challenge_names:
980+
click.secho(
981+
f"Found challenge '{remote_challenge['name']}' in CTFd, but not in .ctf/config\n"
982+
"Please add the local challenge if you wish to manage it with ctfcli\n",
983+
fg="yellow",
984+
)
985+
986+
challenges_in_sync, challenges_out_of_sync = [], []
987+
with click.progressbar(local_challenges, label="Verifying challenges") as challenges:
988+
for challenge in challenges:
989+
try:
990+
if not challenge.verify(ignore=ignore):
991+
challenges_out_of_sync.append(challenge["name"])
992+
else:
993+
challenges_in_sync.append(challenge["name"])
994+
995+
except ChallengeException as e:
996+
click.secho(str(e), fg="red")
997+
failed_verifications.append(challenge["name"])
998+
999+
if len(failed_verifications) == 0:
1000+
click.secho("Success! All challenges verified!", fg="green")
1001+
1002+
if len(challenges_in_sync) > 0:
1003+
click.secho("Challenges in sync:", fg="green")
1004+
for challenge in challenges_in_sync:
1005+
click.echo(f" - {challenge}")
1006+
1007+
if len(challenges_out_of_sync) > 0:
1008+
click.secho("Challenges out of sync:", fg="yellow")
1009+
for challenge in challenges_out_of_sync:
1010+
click.echo(f" - {challenge}")
1011+
1012+
if len(challenges_out_of_sync) > 1:
1013+
return 2
1014+
1015+
return 1
1016+
1017+
click.secho("Verification failed for:", fg="red")
1018+
for challenge in failed_verifications:
1019+
click.echo(f" - {challenge}")
1020+
1021+
return 1
1022+
1023+
def format(self, challenge: str = None) -> int:
1024+
config = Config()
1025+
challenge_keys = [challenge]
1026+
1027+
# Get all local challenges if not specifying a challenge
1028+
if challenge is None:
1029+
challenge_keys = config.challenges.keys()
1030+
1031+
failed_formats = []
1032+
for challenge_key in challenge_keys:
1033+
challenge_path = config.project_path / Path(challenge_key)
1034+
1035+
if not challenge_path.name.endswith(".yml"):
1036+
challenge_path = challenge_path / "challenge.yml"
1037+
1038+
try:
1039+
# load the challenge and save it without changes
1040+
Challenge(challenge_path).save()
1041+
1042+
except ChallengeException as e:
1043+
click.secho(str(e), fg="red")
1044+
failed_formats.append(challenge_key)
1045+
continue
1046+
1047+
if len(failed_formats) == 0:
1048+
click.secho("Success! All challenges formatted!", fg="green")
1049+
return 0
1050+
1051+
click.secho("Format failed for:", fg="red")
1052+
for challenge in failed_formats:
1053+
click.echo(f" - {challenge}")
1054+
1055+
return 1

0 commit comments

Comments
 (0)