diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index e2333e6..4441259 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -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") diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py index d87f807..3105634 100644 --- a/ctfcli/core/challenge.py +++ b/ctfcli/core/challenge.py @@ -67,6 +67,7 @@ class Challenge(dict): "hints", "requirements", "next", + "module", "state", "version", ] @@ -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 + return bool(key == "next" and value is None) @staticmethod @@ -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() @@ -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.) @@ -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) @@ -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 + return challenge # Create a dictionary of remote files in { basename: {"url": "", "location": ""} } format @@ -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: @@ -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() + # Add solution if "solution" not in ignore: self._create_solution() @@ -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", diff --git a/ctfcli/spec/challenge-example.yml b/ctfcli/spec/challenge-example.yml index d7d5d0f..a1abfef 100644 --- a/ctfcli/spec/challenge-example.yml +++ b/ctfcli/spec/challenge-example.yml @@ -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. diff --git a/tests/core/test_challenge.py b/tests/core/test_challenge.py index de892db..9dcfcfb 100644 --- a/tests/core/test_challenge.py +++ b/tests/core/test_challenge.py @@ -1066,6 +1066,162 @@ def test_updates_requirements(self, mock_api_constructor: MagicMock, *args, **kw mock_api.post.assert_not_called() mock_api.delete.assert_not_called() + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_updates_module_by_name(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"module": "Test Module"}) + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/modules": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": [ + {"id": 1, "name": "Other Module", "description": None}, + {"id": 2, "name": "Test Module", "description": None}, + ], + } + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls([call("/api/v1/modules")]) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json={"module_id": 2}), + call().raise_for_status(), + ] + ) + + # the module already exists, so it should not be created + mock_api.post.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.click.secho") + @mock.patch("ctfcli.core.challenge.API") + def test_creates_module_if_missing(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"module": "New Module"}) + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/modules": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + return MagicMock() + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/modules": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": {"id": 7, "name": "New Module", "description": None}, + } + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + challenge.sync(ignore=["files"]) + + mock_api.get.assert_has_calls([call("/api/v1/modules")]) + mock_api.post.assert_has_calls([call("/api/v1/modules", json={"name": "New Module"})]) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json={"module_id": 7}), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_numeric_module_treated_as_name(self, mock_api_constructor: MagicMock, *args, **kwargs): + # a numeric module (YAML loads "42" as an int) is always treated as a name, + # not a module id - it is resolved (and created if missing) by name + challenge = Challenge(self.minimal_challenge, {"module": 42}) + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/modules": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + return MagicMock() + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/modules": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": {"id": 7, "name": "42", "description": None}, + } + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + challenge.sync(ignore=["files"]) + + # the numeric name is resolved against the remote and created as a string name + mock_api.get.assert_has_calls([call("/api/v1/modules")]) + mock_api.post.assert_has_calls([call("/api/v1/modules", json={"name": "42"})]) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json={"module_id": 7}), + call().raise_for_status(), + ] + ) + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_removes_module_with_explicit_null(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"module": None}) + + mock_api: MagicMock = mock_api_constructor.return_value + challenge.sync(ignore=["files"]) + + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/1", json={"module_id": None}), + call().raise_for_status(), + ] + ) + mock_api.post.assert_not_called() + + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_does_not_touch_module_if_absent(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge) + + mock_api: MagicMock = mock_api_constructor.return_value + challenge.sync(ignore=["files"]) + + # without a module key in challenge.yml the remote module assignment should be left untouched + self.assertNotIn(call("/api/v1/modules"), mock_api.get.call_args_list) + for patch_call in mock_api.patch.call_args_list: + self.assertNotIn("module_id", patch_call.kwargs.get("json", {})) + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) @mock.patch("ctfcli.core.challenge.click.secho") @mock.patch("ctfcli.core.challenge.API") @@ -1365,6 +1521,7 @@ def test_does_not_update_ignored_attributes(self): "files", "hints", "requirements", + "module", "solution", # fmt: on ] @@ -1444,6 +1601,9 @@ def test_does_not_update_ignored_attributes(self): if p in ["flags", "topics", "tags", "files", "hints", "requirements"]: challenge[p] = ["new-value"] + if p == "module": + challenge[p] = "new-value" + if p == "solution": challenge[p] = "challenge.yml" @@ -1620,6 +1780,59 @@ def mock_post(*args, **kwargs): any_order=True, ) + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.click.secho") + @mock.patch("ctfcli.core.challenge.API") + def test_creates_challenge_with_module(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"module": "New Module"}) + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/modules": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": []} + return mock_response + + return MagicMock() + + def mock_post(*args, **kwargs): + path = args[0] + + if path == "/api/v1/challenges": + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "data": {"id": 3}} + return mock_response + + if path == "/api/v1/modules": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": {"id": 7, "name": "New Module", "description": None}, + } + return mock_response + + return MagicMock() + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = mock_get + mock_api.post.side_effect = mock_post + + challenge.create() + + mock_api.post.assert_has_calls( + [ + call("/api/v1/challenges", json=ANY), + call("/api/v1/modules", json={"name": "New Module"}), + ] + ) + mock_api.patch.assert_has_calls( + [ + call("/api/v1/challenges/3", json={"module_id": 7}), + call().raise_for_status(), + ] + ) + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) @mock.patch("ctfcli.core.challenge.API") def test_exits_if_files_do_not_exist(self, mock_api_constructor: MagicMock, *args, **kwargs): @@ -1639,7 +1852,7 @@ def test_does_not_set_ignored_attributes(self): # fmt:off properties = [ "value", "category", "description", "attribution", "attempts", "connection_info", "state", # simple types - "extra", "flags", "topics", "tags", "files", "hints", "requirements", "solution" # complex types + "extra", "flags", "topics", "tags", "files", "hints", "requirements", "module", "solution" # complex types ] # fmt:on @@ -1700,6 +1913,9 @@ def test_does_not_set_ignored_attributes(self): if p in ["flags", "topics", "tags", "files", "hints", "requirements"]: challenge[p] = ["new-value"] + if p == "module": + challenge[p] = "new-value" + if p == "solution": challenge[p] = "challenge.yml" @@ -2145,6 +2361,7 @@ def test_normalize_fetches_and_normalizes_challenge(self, mock_api_constructor: "hints": ["free hint", {"content": "paid hint", "cost": 100}], "topics": ["topic-1", "topic-2"], "next": None, + "module": None, "requirements": {"prerequisites": ["First Test Challenge", "Other Test Challenge"], "anonymize": False}, "extra": { "initial": 100, @@ -2155,6 +2372,53 @@ def test_normalize_fetches_and_normalizes_challenge(self, mock_api_constructor: normalized_data, ) + @mock.patch("ctfcli.core.challenge.API") + def test_normalize_resolves_module_name(self, mock_api_constructor: MagicMock): + mock_api: MagicMock = mock_api_constructor.return_value + + def mock_get(*args, **kwargs): + path = args[0] + + if path == "/api/v1/modules/5": + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": {"id": 5, "name": "Test Module", "description": None}, + } + return mock_response + + return self.mock_get(*args, **kwargs) + + mock_api.get.side_effect = mock_get + + challenge = Challenge(self.full_challenge) + challenge.challenge_id = 3 + + normalized_data = challenge._normalize_challenge( + { + "name": "Test Challenge", + "description": "Test Description", + "max_attempts": 5, + "module_id": 5, + } + ) + + self.assertEqual("Test Module", normalized_data["module"]) + + @mock.patch("ctfcli.core.challenge.API") + def test_compare_challenge_module(self, mock_api_constructor: MagicMock): + challenge = Challenge(self.full_challenge) + challenge.challenge_id = 3 + + # modules are compared by name + self.assertTrue(challenge._compare_challenge_module("Test Module", "Test Module")) + self.assertTrue(challenge._compare_challenge_module(None, None)) + self.assertFalse(challenge._compare_challenge_module("Other Module", "Test Module")) + + # a numeric name (loaded from YAML as an int) is coerced to a string + self.assertTrue(challenge._compare_challenge_module(42, "42")) + self.assertFalse(challenge._compare_challenge_module(42, "Test Module")) + @mock.patch("ctfcli.core.challenge.API") def test_verify_checks_if_challenge_is_the_same(self, mock_api_constructor: MagicMock): mock_api: MagicMock = mock_api_constructor.return_value