-
Notifications
You must be signed in to change notification settings - Fork 87
Add verify and pull functionality #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
46f4cda
0e67317
16fdece
0a02509
4a2bdc1
892181b
fc286b8
ec9d3c0
1c7c4d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,6 +82,30 @@ Syncing buffer_overflow | |
| Success! | ||
| ``` | ||
|
|
||
| ## 5. Verify & pull challenges | ||
|
|
||
| If, at any point, changes may have been made to a challenge through the CTFd UI by an admin. To verify that your challenge.yml file matches remote contents, you can use: | ||
|
|
||
| ``` | ||
| ❯ ctf challenge verify [challenge.yml | DIRECTORY] [--verify-files] [--verify-defaults] | ||
| ``` | ||
|
|
||
| If the `--verify-files` flag is set, challenge files will be downloaded and the binary files will be compared. | ||
|
|
||
| If the `--verify-defaults` flag is set, challenge files will be verified to make sure they include default optional keys present on CTFd. | ||
| If you want to pull down the latest version of the challenge, and its challenge files, you can use: | ||
|
Comment on lines
+93
to
+96
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See below regarding just showing basic concepts of the idea
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
|
|
||
| ``` | ||
| ❯ ctf challenge verify [challenge.yml | DIRECTORY] [--update_files] [--create-files] [--create_defaults] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be pull?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, sorry that is a typo |
||
| ``` | ||
|
|
||
| If the `--update_files` flag is set, the latest version of every file will be redownloaded from CTFd. | ||
|
|
||
| If the `--create-files` flag is set, any new files added to through the CTFd UI will be downloaded to the same directory as the `challenge.yml` file. | ||
|
|
||
| If the `--create-defaults` flag is set, any optional default values will be added to the `challenge.yml`. | ||
|
|
||
| **This is a destructive action! It will overwrite the local version of `challenge.yml` with the version on CTFd!** | ||
|
Comment on lines
+102
to
+108
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not a fan of listing out all the switches here. The README should show just basic concepts of the idea and the help text should show how to use the switches.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good, I will remove them here |
||
| # Challenge Templates | ||
|
|
||
| `ctfcli` contains pre-made challenge templates to make it faster to create CTF challenges with safe defaults. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| from collections import OrderedDict | ||
| from pathlib import Path | ||
| import subprocess | ||
|
|
||
|
|
@@ -404,3 +405,228 @@ def lint_challenge(path): | |
| exit(1) | ||
|
|
||
| exit(0) | ||
|
|
||
| def get_challenge_details(challenge_id): | ||
| s = generate_session() | ||
| r = s.get(f"/api/v1/challenges/{challenge_id}", json=True) | ||
| r.raise_for_status() | ||
| challenge = r.json()["data"] | ||
| # Remove non-yaml fields | ||
|
|
||
| challenge.pop("id") | ||
| challenge.pop("type_data") | ||
| challenge.pop("view") | ||
| challenge.pop("solves") | ||
| challenge.pop("solved_by_me") | ||
|
|
||
| # Normalize fields to ctfcli format | ||
| challenge['attempts'] = challenge['max_attempts'] | ||
| challenge.pop("max_attempts") | ||
| challenge['description'] = challenge['description'].replace('\r\n', '\n') | ||
|
|
||
| for key in ['initial', 'decay', 'minimum']: | ||
| if key in challenge: | ||
| challenge['extra'][key] = challenge[key] | ||
| challenge.pop(key) | ||
|
|
||
| # Add flags | ||
| r = s.get(f"/api/v1/challenges/{challenge_id}/flags", json=True) | ||
| r.raise_for_status() | ||
| flags = r.json()["data"] | ||
| challenge["flags"] = [f["content"] if f["type"] == "static" and (f["data"] == None or f["data"] == "") else { "content": f["content"], "type": f["type"], "data": f["data"] } for f in flags] | ||
|
|
||
| # Add tags | ||
| r = s.get(f"/api/v1/challenges/{challenge_id}/tags", json=True) | ||
| r.raise_for_status() | ||
| tags = r.json()["data"] | ||
| challenge["tags"] = [t["value"] for t in tags] | ||
|
|
||
| # Add hints | ||
| r = s.get(f"/api/v1/challenges/{challenge_id}/hints", json=True) | ||
| r.raise_for_status() | ||
| hints = r.json()["data"] | ||
| challenge["hints"] = [{ "content": h["content"], "cost": h["cost"] } if h["cost"] > 0 else h["content"] for h in hints] | ||
|
|
||
| # Add topics | ||
| r = s.get(f"/api/v1/challenges/{challenge_id}/topics", json=True) | ||
| r.raise_for_status() | ||
| topics = r.json()["data"] | ||
| challenge["topics"] = [t["value"] for t in topics] | ||
|
|
||
| # Add requirements | ||
| r = s.get(f"/api/v1/challenges/{challenge_id}/requirements", json=True) | ||
| r.raise_for_status() | ||
| requirements = (r.json().get("data") or {}).get("prerequisites", []) | ||
| if len(requirements) > 0: | ||
| # Prefer challenge names over IDs | ||
| r = s.get("/api/v1/challenges", json=True) | ||
| r.raise_for_status() | ||
| challenges = r.json()["data"] | ||
| challenge["requirements"] = [c["name"] for c in challenges if c["id"] in requirements] | ||
|
|
||
| return challenge | ||
|
|
||
| def is_default(key, value): | ||
| if key == "connection_info" and value == None: | ||
| return True | ||
| if key == "attempts" and value == 0: | ||
| return True | ||
| if key == "state" and value == "visible": | ||
| return True | ||
| if key == "type" and value == "standard": | ||
| return True | ||
| if key in ["tags", "hints", "topics", "requirements"] and value == []: | ||
| return True | ||
| return False | ||
|
|
||
| def verify_challenge(challenge, ignore=(), verify_files=False, verify_defaults=False, _verify_new_files=True): | ||
| """ | ||
| Verify that the challenge.yml matches the remote challenge if one exists with the same name | ||
| """ | ||
| s = generate_session() | ||
|
|
||
| installed_challenges = load_installed_challenges() | ||
| for c in installed_challenges: | ||
| if c["name"] == challenge["name"]: | ||
| challenge_id = c["id"] | ||
| break | ||
| else: | ||
| return | ||
|
|
||
| remote_challenge = get_challenge_details(challenge_id) | ||
| for key in remote_challenge: | ||
| if key in ignore: | ||
| continue | ||
| # Special validation needed for files | ||
| if key == "files": | ||
| # Get base file name of challenge files | ||
| local_files = {Path(f).name : f for f in challenge[key]} | ||
|
|
||
| for f in remote_challenge["files"]: | ||
| # Get base file name | ||
| f_base = f.split("/")[-1].split('?token=')[0] | ||
| if f_base not in local_files and _verify_new_files: | ||
| raise Exception(f"Remote challenge has file {f_base} that is not present locally") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These exceptions should be some kind of custom exception or some other higher level exception instead of the base Exception |
||
| else: | ||
| if verify_files: | ||
| # Download remote file and compare contents | ||
| req = s.get(f) | ||
| req.raise_for_status() | ||
| remote_file = req.content | ||
| local_file = Path(challenge.directory, local_files[f_base]).read_bytes() | ||
| if remote_file != local_file: | ||
| raise Exception(f"Remote challenge file {f_base} does not match local file") | ||
|
Comment on lines
+513
to
+518
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe it makes more sense to use filecmp and write the downloaded file to a tempfile?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just so we don't read the local file into memory? |
||
|
|
||
| elif key not in challenge: | ||
| # Ignore optional keys with default values | ||
| if is_default(key, remote_challenge[key]) and not verify_defaults: | ||
| continue | ||
|
|
||
| raise Exception(f"Missing field {key} in challenge.yml") | ||
|
|
||
| elif challenge[key] != remote_challenge[key]: | ||
| raise Exception(f"Field {key} in challenge.yml does not match remote challenge") | ||
|
|
||
| def pull_challenge(challenge, ignore=(), update_files=False, create_files=False, create_defaults=False): | ||
| """ | ||
| Rewrite challenge.yml and local files to match the remote challenge | ||
| """ | ||
| # Prefer multi-line YAML formatting (https://stackoverflow.com/q/8640959/15261182) | ||
| def str_presenter(dumper, data): | ||
| if len(data.splitlines()) > 1 or '\n' in data: | ||
| text_list = [line.rstrip() for line in data.splitlines()] | ||
| fixed_data = "\n".join(text_list) | ||
| return dumper.represent_scalar('tag:yaml.org,2002:str', fixed_data, style='|') | ||
| elif len(data) > 80: | ||
| return dumper.represent_scalar('tag:yaml.org,2002:str', data.rstrip(), style='>') | ||
| return dumper.represent_scalar('tag:yaml.org,2002:str', data) | ||
|
|
||
| yaml.add_representer(str, str_presenter) | ||
| yaml.representer.SafeRepresenter.add_representer(str, str_presenter) | ||
|
|
||
|
|
||
|
|
||
|
|
||
| # Update challenge.yml if verification shows that something changed | ||
| try: | ||
| verify_challenge(challenge, ignore=ignore, verify_files=update_files, verify_defaults=create_defaults, _verify_new_files=create_files) | ||
| click.secho(f"Challenge {challenge['name']} is already up to date", fg="green") | ||
| return | ||
| except Exception: | ||
| pass | ||
|
|
||
| s = generate_session() | ||
| installed_challenges = load_installed_challenges() | ||
| for c in installed_challenges: | ||
| if c["name"] == challenge["name"]: | ||
| challenge_id = c["id"] | ||
| break | ||
| else: | ||
| return | ||
|
|
||
| remote_details = get_challenge_details(challenge_id) | ||
|
|
||
| local_files = {Path(f).name : f for f in challenge.get("files",[])} | ||
| # Update files | ||
| for f in remote_details["files"]: | ||
| # Get base file name | ||
| f_base = f.split("/")[-1].split('?token=')[0] | ||
| if f_base not in local_files and create_files: | ||
| print(f"Creating file {f_base} for challenge {challenge['name']}") | ||
| # Download remote file and save locally | ||
| req = s.get(f) | ||
| req.raise_for_status() | ||
| Path(challenge.directory, f_base).write_bytes(req.content) | ||
| if "files" not in challenge: | ||
| challenge["files"] = [] | ||
| challenge["files"].append(f_base) | ||
|
|
||
| elif f_base in local_files and update_files: | ||
| # Download remote file and replace local file | ||
| print(f"Updating file {f_base} for challenge {challenge['name']}") | ||
| req = s.get(f) | ||
| req.raise_for_status() | ||
| remote_file = req.content | ||
| local_file = Path(challenge.directory, local_files[f_base]) | ||
| local_file.write_bytes(remote_file) | ||
|
|
||
| # Remove files that are no longer present on the remote challenge | ||
| remote_cleaned_files = [f.split("/")[-1].split('?token=')[0] for f in remote_details["files"]] | ||
| challenge["files"] = [f for f in challenge.get("files",[]) if Path(f).name in remote_cleaned_files] | ||
| del remote_details["files"] | ||
|
|
||
| print(f"Updating challenge.yml for {challenge['name']}") | ||
|
|
||
| # Prefer ordering remote details as described by spec | ||
| preferred_order = ["name", "category", "description", \ | ||
| "value", "type", "connection_info", "attempts", \ | ||
| "flags", "topics", "tags", "files", "hints", "requirements", "state"] | ||
|
|
||
| # Merge local and remote challenge.yml & Preserve local keys + order | ||
| updated_challenge = dict(challenge) | ||
|
|
||
| # Ignore optional fields with default values | ||
| remote_details_updates = {} | ||
| for k, v in remote_details.items(): | ||
| # If the key value changed, we want to update it | ||
| if k in challenge and challenge[k] != v: | ||
| remote_details_updates[k] = v | ||
| elif not is_default(k, v) or create_defaults: | ||
| remote_details_updates[k] = v | ||
|
|
||
| # Add all preferred keys | ||
| for key in preferred_order: | ||
| if key in remote_details_updates and key not in ignore: | ||
| updated_challenge[key] = remote_details_updates[key] | ||
|
|
||
| # Add remaining keys | ||
| for key in remote_details_updates: | ||
| if key not in preferred_order and key not in ignore: | ||
| updated_challenge[key] = remote_details_updates[key] | ||
|
|
||
| # Hack: remove tabs in multiline strings | ||
| updated_challenge['description'] = updated_challenge['description'].replace('\t', '') | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does this need to be done?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you referring to the removal of tabs? |
||
|
|
||
| with open(challenge.file_path, "w") as f: | ||
| yaml.dump(updated_challenge, f, allow_unicode=True, sort_keys=False) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that this should be
--filesand--ignore-defaults. I think the default checking behavior (which I'm not totally clear about what it does) should be implicit. As for the files, users should be warned when they run the command that files are not verified unless you provide the--filesswitch.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense