diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py
index 96cd87f..76ddfc1 100644
--- a/ctfcli/core/challenge.py
+++ b/ctfcli/core/challenge.py
@@ -56,6 +56,7 @@ class Challenge(dict):
"host",
"connection_info",
"healthcheck",
+ "solution",
"attempts",
"logic",
"flags",
@@ -442,6 +443,127 @@ def _create_hints(self):
r = self.api.post("/api/v1/hints", json=hint_payload)
r.raise_for_status()
+ def _parse_solution_definition(self) -> tuple[str, str] | None:
+ solution = self.get("solution", None)
+ if not solution:
+ return None
+
+ if type(solution) == str:
+ return solution, "hidden"
+
+ if type(solution) != dict:
+ click.secho(
+ "The solution field must be a string path or an object with path and state",
+ fg="red",
+ )
+ return None
+
+ solution_path = solution.get("path")
+ if type(solution_path) != str or not solution_path:
+ click.secho("The solution object must define a non-empty string path field", fg="red")
+ return None
+
+ solution_state = solution.get("state", "hidden")
+ if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]:
+ click.secho("The solution state must be one of: hidden, visible, solved", fg="red")
+ return None
+
+ return solution_path, solution_state
+
+ def _resolve_solution_path(self) -> tuple[Path, str] | None:
+ parsed_solution = self._parse_solution_definition()
+ if not parsed_solution:
+ return None
+
+ solution_path_string, solution_state = parsed_solution
+ solution_path = self.challenge_directory / solution_path_string
+ if not solution_path.is_file():
+ click.secho(
+ f"Solution file '{solution_path_string}' specified, but not found at {solution_path}",
+ fg="red",
+ )
+ return None
+
+ return solution_path, solution_state
+
+ def _delete_existing_solution(self):
+ remote_solutions = self.api.get("/api/v1/solutions").json()["data"]
+ for solution in remote_solutions:
+ if solution["challenge_id"] == self.challenge_id:
+ r = self.api.delete(f"/api/v1/solutions/{solution['id']}")
+ r.raise_for_status()
+
+ def _get_existing_solution_id(self) -> int | None:
+ r = self.api.get("/api/v1/solutions")
+ r.raise_for_status()
+ remote_solutions = r.json().get("data") or []
+ for solution in remote_solutions:
+ if solution["challenge_id"] == self.challenge_id:
+ return solution["id"]
+ return None
+
+ def _create_solution(self):
+ resolved_solution = self._resolve_solution_path()
+ if not resolved_solution:
+ return
+ solution_path, solution_state = resolved_solution
+
+ solution_id = self._get_existing_solution_id()
+ if solution_id is None:
+ solution_payload_create = {"challenge_id": self.challenge_id, "state": solution_state, "content": ""}
+
+ r = self.api.post("/api/v1/solutions", json=solution_payload_create)
+ r.raise_for_status()
+ solution_id = r.json()["data"]["id"]
+ else:
+ # Keep solution state in sync and clear stale content before rebuilding references.
+ r = self.api.patch(
+ f"/api/v1/solutions/{solution_id}",
+ json={"state": solution_state, "content": ""},
+ )
+ r.raise_for_status()
+
+ with solution_path.open("r") as solution_file:
+ content = solution_file.read()
+
+ # Find all images in the content (markdown format; ignore html format)
+ # Markdown format: 
+ # Returns tuples: (full_match, alt_text, image_path)
+ markdown_images = re.findall(r"(!\[([^\]]*)\]\(([^\)]+)\))", content)
+
+ # Find all snippet includes (MkDocs style: --8<-- "filename")
+ # Returns tuples: (full_match, filename)
+ snippet_includes = re.findall(r'(--8<--\s+["\']([^"\']+)["\'])', content)
+
+ for mdx, alt, path in markdown_images:
+ new_file = ("file", open(solution_path.parent / path, mode="rb"))
+ file_payload = {
+ "type": "solution",
+ "solution_id": solution_id,
+ }
+
+ # Specifically use data= here to send multipart/form-data
+ r = self.api.post("/api/v1/files", files=[new_file], data=file_payload)
+ r.raise_for_status()
+ resp = r.json()
+ server_location = resp["data"][0]["location"]
+ content = content.replace(mdx, f"")
+
+ # Process snippet includes (--8<-- "filename")
+ for full_match, filename in snippet_includes:
+ snippet_file_path = solution_path.parent / filename
+ if snippet_file_path.exists():
+ with snippet_file_path.open("r") as snippet_file:
+ snippet_content = snippet_file.read()
+ # Replace the --8<-- directive with the actual file content
+ content = content.replace(full_match, snippet_content)
+ else:
+ log.warning(f"Snippet file not found: {filename}")
+
+ solution_payload_patch = {"content": content}
+ r = self.api.patch(f"/api/v1/solutions/{solution_id}", json=solution_payload_patch)
+ r.raise_for_status()
+
def _set_required_challenges(self):
remote_challenges = self.load_installed_challenges()
required_challenges = []
@@ -796,6 +918,10 @@ def sync(self, ignore: tuple[str] = ()) -> None:
if "next" not in ignore:
self._set_next(_next)
+ if "solution" not in ignore:
+ # self._delete_existing_solution()
+ self._create_solution()
+
make_challenge_visible = False
# Bring back the challenge to be visible if:
@@ -880,6 +1006,10 @@ def create(self, ignore: tuple[str] = ()) -> None:
if "next" not in ignore:
self._set_next(_next)
+ # Add solution
+ if "solution" not in ignore:
+ self._create_solution()
+
# Bring back the challenge if it's supposed to be visible
# Either explicitly, or by assuming the default value (possibly because the state is ignored)
if challenge.get("state", "visible") == "visible" or "state" in ignore:
@@ -950,6 +1080,35 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool:
f"Challenge file '{challenge_file}' specified, but not found at {challenge_file_path}"
)
+ # Check that the optional solution file exists
+ solution = self.get("solution", None)
+ if solution:
+ solution_file = None
+ solution_state = "hidden"
+
+ if type(solution) == str:
+ solution_file = solution
+ elif type(solution) == dict:
+ solution_file = solution.get("path")
+ if "visibility" in solution:
+ issues["fields"].append("The solution object no longer supports visibility. Use state instead.")
+ solution_state = solution.get("state", "hidden")
+
+ if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]:
+ issues["fields"].append("The solution state must be one of: hidden, visible, solved")
+
+ else:
+ issues["fields"].append("The solution field must be a string path or an object with path and state")
+
+ if type(solution_file) != str or not solution_file:
+ issues["fields"].append("The solution object must define a non-empty string path field")
+ else:
+ solution_file_path = self.challenge_directory / solution_file
+ if solution_file_path.is_file() is False:
+ issues["files"].append(
+ f"Solution file '{solution_file}' specified, but not found at {solution_file_path}"
+ )
+
# Check that files don't have a flag in them
for challenge_file in files:
challenge_file_path = self.challenge_directory / challenge_file
diff --git a/ctfcli/spec/challenge-example.yml b/ctfcli/spec/challenge-example.yml
index ecee287..33f4cd3 100644
--- a/ctfcli/spec/challenge-example.yml
+++ b/ctfcli/spec/challenge-example.yml
@@ -48,6 +48,15 @@ connection_info: nc hostname 12345
# ./writeup/exploit.sh --connection-info "nc hostname 12345"
healthcheck: writeup/exploit.sh
+# solution is used to provide a path to the challenge solution document.
+# The file path is relative to this challenge.yml file.
+# If provided as a string path, ctfcli uploads it as a hidden CTFd solution during sync.
+# You can also use an object:
+# solution:
+# path: writeup/WRITEUP.md
+# state: solved # hidden | visible | solved
+solution: writeup/WRITEUP.md
+
# Can be removed if unused
attempts: 5
diff --git a/tests/core/test_challenge.py b/tests/core/test_challenge.py
index fe1e2a4..ce95a8c 100644
--- a/tests/core/test_challenge.py
+++ b/tests/core/test_challenge.py
@@ -134,6 +134,217 @@ def test_load_installed_challenges(self, mock_api: MagicMock):
mock_get.assert_called_once_with("/api/v1/challenges?view=admin")
+class TestChallengeSolutions(unittest.TestCase):
+ minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml"
+ solution_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-solution" / "challenge.yml"
+
+ def test_resolves_solution_from_specified_path(self):
+ challenge = Challenge(self.minimal_challenge, {"solution": "challenge.yml"})
+ solution_path, solution_state = challenge._resolve_solution_path()
+ self.assertEqual(solution_path, challenge.challenge_directory / "challenge.yml")
+ self.assertEqual(solution_state, "hidden")
+
+ def test_resolves_solution_object_from_specified_path(self):
+ challenge = Challenge(
+ self.minimal_challenge,
+ {
+ "solution": {
+ "path": "challenge.yml",
+ "state": "solved",
+ }
+ },
+ )
+ solution_path, solution_state = challenge._resolve_solution_path()
+ self.assertEqual(solution_path, challenge.challenge_directory / "challenge.yml")
+ self.assertEqual(solution_state, "solved")
+
+ def test_resolves_solution_object_with_state_from_specified_path(self):
+ challenge = Challenge(
+ self.minimal_challenge,
+ {
+ "solution": {
+ "path": "challenge.yml",
+ "state": "visible",
+ }
+ },
+ )
+ solution_path, solution_state = challenge._resolve_solution_path()
+ self.assertEqual(solution_path, challenge.challenge_directory / "challenge.yml")
+ self.assertEqual(solution_state, "visible")
+
+ def test_does_not_resolve_solution_if_not_specified(self):
+ challenge = Challenge(self.minimal_challenge)
+ self.assertIsNone(challenge._resolve_solution_path())
+
+ @mock.patch("ctfcli.core.challenge.click.secho")
+ def test_does_not_resolve_solution_if_missing(self, mock_secho: MagicMock):
+ challenge = Challenge(self.minimal_challenge, {"solution": "writeup/WRITEUP.md"})
+ self.assertIsNone(challenge._resolve_solution_path())
+ mock_secho.assert_called_once_with(
+ f"Solution file 'writeup/WRITEUP.md' specified, but not found at "
+ f"{challenge.challenge_directory / 'writeup/WRITEUP.md'}",
+ fg="red",
+ )
+
+ @mock.patch("ctfcli.core.challenge.API")
+ def test_creates_solution_from_specified_path(self, mock_api_constructor: MagicMock):
+ challenge = Challenge(self.minimal_challenge, {"solution": "challenge.yml"})
+ challenge.challenge_id = 1
+
+ def mock_get(*args, **kwargs):
+ path = args[0]
+ if path == "/api/v1/solutions":
+ 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/solutions":
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"success": True, "data": {"id": 5}}
+ 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_solution()
+
+ mock_api.post.assert_has_calls(
+ [call("/api/v1/solutions", json={"challenge_id": 1, "state": "hidden", "content": ""})]
+ )
+ mock_api.patch.assert_has_calls([call("/api/v1/solutions/5", json={"content": ANY})])
+
+ @mock.patch("ctfcli.core.challenge.API")
+ def test_creates_solution_from_object_with_state(self, mock_api_constructor: MagicMock):
+ challenge = Challenge(self.minimal_challenge, {"solution": {"path": "challenge.yml", "state": "visible"}})
+ challenge.challenge_id = 1
+
+ def mock_get(*args, **kwargs):
+ path = args[0]
+ if path == "/api/v1/solutions":
+ 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/solutions":
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"success": True, "data": {"id": 5}}
+ 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_solution()
+
+ mock_api.post.assert_has_calls(
+ [call("/api/v1/solutions", json={"challenge_id": 1, "state": "visible", "content": ""})]
+ )
+
+ @mock.patch("ctfcli.core.challenge.API")
+ def test_updates_existing_solution_instead_of_creating_duplicate(self, mock_api_constructor: MagicMock):
+ challenge = Challenge(self.minimal_challenge, {"solution": {"path": "challenge.yml", "state": "solved"}})
+ challenge.challenge_id = 1
+
+ def mock_get(*args, **kwargs):
+ path = args[0]
+ if path == "/api/v1/solutions":
+ mock_response = MagicMock()
+ mock_response.json.return_value = {
+ "success": True,
+ "data": [{"id": 9, "challenge_id": 1, "state": "hidden", "content": "old"}],
+ }
+ return mock_response
+ return MagicMock()
+
+ mock_api: MagicMock = mock_api_constructor.return_value
+ mock_api.get.side_effect = mock_get
+
+ challenge._create_solution()
+
+ mock_api.post.assert_not_called()
+ mock_api.patch.assert_has_calls(
+ [
+ call("/api/v1/solutions/9", json={"state": "solved", "content": ""}),
+ call("/api/v1/solutions/9", json={"content": ANY}),
+ ],
+ any_order=True,
+ )
+
+ @mock.patch("ctfcli.core.challenge.API")
+ def test_does_not_create_solution_if_not_specified(self, mock_api_constructor: MagicMock):
+ challenge = Challenge(self.minimal_challenge)
+ challenge.challenge_id = 1
+
+ mock_api: MagicMock = mock_api_constructor.return_value
+ challenge._create_solution()
+
+ mock_api.post.assert_not_called()
+ mock_api.patch.assert_not_called()
+
+ @mock.patch("ctfcli.core.challenge.API")
+ def test_solution_uploads_markdown_images_and_inlines_snippets(self, mock_api_constructor: MagicMock):
+ challenge = Challenge(self.solution_challenge)
+ challenge.challenge_id = 1
+
+ def mock_get(*args, **kwargs):
+ path = args[0]
+ if path == "/api/v1/solutions":
+ 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/solutions":
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"success": True, "data": {"id": 5}}
+ return mock_response
+
+ if path == "/api/v1/files":
+ mock_response = MagicMock()
+ mock_response.json.return_value = {
+ "success": True,
+ "data": [{"location": "solution-uploaded/test.png"}],
+ }
+ 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_solution()
+
+ mock_api.post.assert_has_calls(
+ [
+ call("/api/v1/solutions", json={"challenge_id": 1, "state": "hidden", "content": ""}),
+ call("/api/v1/files", files=ANY, data={"type": "solution", "solution_id": 5}),
+ ]
+ )
+ mock_api.patch.assert_called_once()
+ patched_content = mock_api.patch.call_args.kwargs["json"]["content"]
+
+ self.assertIn("", patched_content)
+ self.assertIn('print("snippet from python")', patched_content)
+ self.assertIn('
', patched_content)
+
+
class TestSyncChallenge(unittest.TestCase):
installed_challenges = [
{
@@ -1055,6 +1266,7 @@ def test_does_not_update_ignored_attributes(self):
"files",
"hints",
"requirements",
+ "solution",
# fmt: on
]
@@ -1133,6 +1345,9 @@ def test_does_not_update_ignored_attributes(self):
if p in ["flags", "topics", "tags", "files", "hints", "requirements"]:
challenge[p] = ["new-value"]
+ if p == "solution":
+ challenge[p] = "challenge.yml"
+
challenge.sync(ignore=[p])
mock_api: MagicMock = mock_api_constructor.return_value
@@ -1267,6 +1482,45 @@ def mock_post(*args, **kwargs):
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_creates_solution_on_create(self, mock_api_constructor: MagicMock, *args, **kwargs):
+ challenge = Challenge(self.minimal_challenge, {"solution": "challenge.yml"})
+
+ 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/solutions":
+ mock_response = MagicMock()
+ mock_response.json.return_value = {"success": True, "data": {"id": 5}}
+ return mock_response
+
+ return MagicMock()
+
+ mock_api: MagicMock = mock_api_constructor.return_value
+ mock_api.post.side_effect = mock_post
+
+ challenge.create()
+
+ mock_api.post.assert_has_calls(
+ [
+ call("/api/v1/challenges", json=ANY),
+ call("/api/v1/solutions", json={"challenge_id": 3, "state": "hidden", "content": ""}),
+ ],
+ any_order=True,
+ )
+ mock_api.patch.assert_has_calls(
+ [
+ call("/api/v1/solutions/5", json={"content": ANY}),
+ ],
+ any_order=True,
+ )
+
@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):
@@ -1286,7 +1540,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" # complex types
+ "extra", "flags", "topics", "tags", "files", "hints", "requirements", "solution" # complex types
]
# fmt:on
@@ -1347,6 +1601,9 @@ def test_does_not_set_ignored_attributes(self):
if p in ["flags", "topics", "tags", "files", "hints", "requirements"]:
challenge[p] = ["new-value"]
+ if p == "solution":
+ challenge[p] = "challenge.yml"
+
def mock_post(*args, **kwargs):
path = args[0]
@@ -1501,6 +1758,52 @@ def test_looks_for_flags_in_dist_files(self, *args, **kwargs):
self.assertDictEqual(expected_lint_issues, e.exception.issues)
+ def test_validates_solution_file_exists(self):
+ challenge = Challenge(self.minimal_challenge, {"solution": "writeup/WRITEUP.md"})
+
+ with self.assertRaises(LintException) as e:
+ challenge.lint(skip_hadolint=True)
+
+ expected_solution_path = (challenge.challenge_directory / "writeup" / "WRITEUP.md").absolute()
+ expected_lint_issues = {
+ "fields": [],
+ "dockerfile": [],
+ "hadolint": [],
+ "files": [f"Solution file 'writeup/WRITEUP.md' specified, but not found at {expected_solution_path}"],
+ }
+
+ self.assertDictEqual(expected_lint_issues, e.exception.issues)
+
+ def test_validates_solution_state(self):
+ challenge = Challenge(self.minimal_challenge, {"solution": {"path": "challenge.yml", "state": "public"}})
+
+ with self.assertRaises(LintException) as e:
+ challenge.lint(skip_hadolint=True)
+
+ expected_lint_issues = {
+ "fields": ["The solution state must be one of: hidden, visible, solved"],
+ "dockerfile": [],
+ "hadolint": [],
+ "files": [],
+ }
+
+ self.assertDictEqual(expected_lint_issues, e.exception.issues)
+
+ def test_rejects_solution_visibility_key(self):
+ challenge = Challenge(self.minimal_challenge, {"solution": {"path": "challenge.yml", "visibility": "visible"}})
+
+ with self.assertRaises(LintException) as e:
+ challenge.lint(skip_hadolint=True)
+
+ expected_lint_issues = {
+ "fields": ["The solution object no longer supports visibility. Use state instead."],
+ "dockerfile": [],
+ "hadolint": [],
+ "files": [],
+ }
+
+ self.assertDictEqual(expected_lint_issues, e.exception.issues)
+
class TestVerifyMirrorChallenge(unittest.TestCase):
installed_challenges = [
diff --git a/tests/fixtures/challenges/test-challenge-solution/challenge.yml b/tests/fixtures/challenges/test-challenge-solution/challenge.yml
new file mode 100644
index 0000000..2f0ca83
--- /dev/null
+++ b/tests/fixtures/challenges/test-challenge-solution/challenge.yml
@@ -0,0 +1,9 @@
+name: Test Challenge Solution
+category: Test
+description: Test challenge with solution fixture
+attribution: Test Attribution
+value: 100
+author: Test
+type: standard
+state: hidden
+solution: writeup/WRITEUP.md
diff --git a/tests/fixtures/challenges/test-challenge-solution/writeup/WRITEUP.md b/tests/fixtures/challenges/test-challenge-solution/writeup/WRITEUP.md
new file mode 100644
index 0000000..740009d
--- /dev/null
+++ b/tests/fixtures/challenges/test-challenge-solution/writeup/WRITEUP.md
@@ -0,0 +1,10 @@
+# Solution
+
+This markdown image should be uploaded and rewritten:
+
+
+This HTML image is currently not rewritten by ctfcli core:
+
+
+Inline snippet:
+--8<-- "src/example.py"
diff --git a/tests/fixtures/challenges/test-challenge-solution/writeup/images/test-html.png b/tests/fixtures/challenges/test-challenge-solution/writeup/images/test-html.png
new file mode 100644
index 0000000..0040c1e
--- /dev/null
+++ b/tests/fixtures/challenges/test-challenge-solution/writeup/images/test-html.png
@@ -0,0 +1 @@
+fake-html-png-bytes
diff --git a/tests/fixtures/challenges/test-challenge-solution/writeup/images/test.png b/tests/fixtures/challenges/test-challenge-solution/writeup/images/test.png
new file mode 100644
index 0000000..87c3e19
--- /dev/null
+++ b/tests/fixtures/challenges/test-challenge-solution/writeup/images/test.png
@@ -0,0 +1 @@
+fake-png-bytes
diff --git a/tests/fixtures/challenges/test-challenge-solution/writeup/src/example.py b/tests/fixtures/challenges/test-challenge-solution/writeup/src/example.py
new file mode 100644
index 0000000..b274895
--- /dev/null
+++ b/tests/fixtures/challenges/test-challenge-solution/writeup/src/example.py
@@ -0,0 +1 @@
+print("snippet from python")