Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ctfcli/cli/challenges.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,7 +795,7 @@ def deploy(
if existing_challenge:
click.secho(f"Updating challenge '{challenge_name}'", fg="blue")
challenge_instance.sync(
ignore=["flags", "topics", "tags", "files", "hints", "requirements", "state"]
ignore=["flags", "topics", "tags", "files", "hints", "requirements", "module", "state"]
)
else:
click.secho(f"Creating challenge '{challenge_name}'", fg="blue")
Expand Down
73 changes: 73 additions & 0 deletions ctfcli/core/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class Challenge(dict):
"hints",
"requirements",
"next",
"module",
"state",
"version",
]
Expand Down Expand Up @@ -133,6 +134,9 @@ def is_default_challenge_property(key: str, value: Any) -> bool:
if key == "requirements" and value == {"prerequisites": [], "anonymize": False}:
return True

if key == "module" and value is None:
return True

Comment on lines +137 to +139
return bool(key == "next" and value is None)

@staticmethod
Expand Down Expand Up @@ -696,6 +700,42 @@ def _set_next(self, _next):
r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=next_payload)
r.raise_for_status()

def _set_module(self):
module = self.get("module", None)

if module is None or module == "":
# explicit null (or empty) module - remove the challenge from its module
module_id = None
else:
# module is always treated as a name - coerce so a numeric name
# (e.g. "2024", which YAML loads as an int) is handled as a string
module = str(module)

# find the module id from the modules installed on the remote
module_id = None
r = self.api.get("/api/v1/modules")
r.raise_for_status()
remote_modules = r.json()["data"]
for remote_module in remote_modules:
if remote_module["name"] == module:
module_id = remote_module["id"]
break

# the module does not exist yet - create it
if module_id is None:
r = self.api.post("/api/v1/modules", json={"name": module})
r.raise_for_status()
module_id = r.json()["data"]["id"]
click.secho(
f'Created module "{module}". '
"Remember to assign audiences to it in the admin panel if you want to restrict access.",
fg="yellow",
)

module_payload = {"module_id": module_id}
r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=module_payload)
r.raise_for_status()

# Compare challenge requirements, will resolve all IDs to names
def _compare_challenge_requirements(self, r1: list[str | int], r2: list[str | int]) -> bool:
remote_challenges = self.load_installed_challenges()
Expand Down Expand Up @@ -735,6 +775,14 @@ def normalize_next(r):

return normalize_next(r1) == normalize_next(r2)

# Compare module assignments - modules are always referenced by name, so a
# numeric name (loaded from YAML as an int) is coerced to a string to compare
def _compare_challenge_module(self, m1: str | int | None, m2: str | int | None) -> bool:
def normalize_module(m):
return None if m is None else str(m)

return normalize_module(m1) == normalize_module(m2)

# Normalize challenge data from the API response to match challenge.yml
# It will remove any extra fields from the remote, as well as expand external references
# that have to be fetched separately (e.g., files, flags, hints, etc.)
Expand Down Expand Up @@ -850,6 +898,7 @@ def _normalize_challenge(self, challenge_data: dict[str, Any]):
r2.raise_for_status()
challenges = r2.json()["data"]
challenge["requirements"]["prerequisites"] = [c["name"] for c in challenges if c["id"] in requirements]

# Add anonymize flag
challenge["requirements"]["anonymize"] = (r.json().get("data") or {}).get("anonymize", False)

Expand All @@ -863,6 +912,16 @@ def _normalize_challenge(self, challenge_data: dict[str, Any]):
else:
challenge["next"] = None

# Add module
module_id = challenge_data.get("module_id")
if module_id:
# Prefer the module name over the ID
r = self.api.get(f"/api/v1/modules/{module_id}")
r.raise_for_status()
challenge["module"] = (r.json().get("data") or {}).get("name", None)
else:
challenge["module"] = None
Comment on lines +915 to +923

return challenge

# Create a dictionary of remote files in { basename: {"url": "", "location": ""} } format
Expand Down Expand Up @@ -997,6 +1056,13 @@ def sync(self, ignore: tuple[str] = ()) -> None:
if "next" not in ignore:
self._set_next(_next)

# Update module
# Only touch the module assignment if the key is present in challenge.yml -
# an explicit "module: null" removes the challenge from its module,
# while an absent key leaves the remote assignment untouched
if "module" in challenge and "module" not in ignore:
self._set_module()

if "solution" not in ignore:
resolved_solution = self._resolve_solution_path()
if not resolved_solution:
Expand Down Expand Up @@ -1087,6 +1153,10 @@ def create(self, ignore: tuple[str] = ()) -> None:
if "next" not in ignore:
self._set_next(_next)

# Assign module
if challenge.get("module") and "module" not in ignore:
self._set_module()
Comment on lines +1156 to +1158

# Add solution
if "solution" not in ignore:
self._create_solution()
Expand Down Expand Up @@ -1295,6 +1365,9 @@ def verify(self, ignore: tuple[str] = ()) -> bool:
if key == "next" and self._compare_challenge_next(challenge[key], normalized_challenge[key]):
continue

if key == "module" and self._compare_challenge_module(challenge[key], normalized_challenge[key]):
continue

click.secho(
f"{key} comparison failed.",
fg="yellow",
Expand Down
13 changes: 12 additions & 1 deletion ctfcli/spec/challenge-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,24 @@ requirements:
# - "Are you alive"
# anonymize: true

# The next is used to display a next recommended challenge to a user when
# The next is used to display a next recommended challenge to a user when
# the user correctly answers the current challenge.
# Can be removed if unused
# Accepts a challenge name as a string, a challenge ID as an integer, or null
# if you want to remove or disable it.
next: null

# The module is used to assign this challenge to a CTFd module.
# Modules group challenges together, and access to a module can be restricted
# to specific audiences (groups of users or teams) in the CTFd admin panel.
# If the module does not exist on the CTFd instance yet, it will be created
# during install/sync - audiences still have to be assigned to it manually.
# Accepts a module name as a string, or null if you want to remove the
# challenge from its module.
# If the field is omitted, the remote module assignment is left untouched.
# Can be removed if unused
module: null

# The state of the challenge.
# If the field is omitted, the challenge is visible by default.
# If provided, the field can take one of two values: hidden, visible.
Expand Down
Loading