Skip to content

Commit fe3a7d9

Browse files
authored
add solution to spec explicitly (#199)
* add solution to spec explicitly * remove visibility alias
1 parent 0c81d6e commit fe3a7d9

8 files changed

Lines changed: 440 additions & 57 deletions

File tree

ctfcli/core/challenge.py

Lines changed: 105 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class Challenge(dict):
5656
"host",
5757
"connection_info",
5858
"healthcheck",
59+
"solution",
5960
"attempts",
6061
"logic",
6162
"flags",
@@ -442,77 +443,100 @@ def _create_hints(self):
442443
r = self.api.post("/api/v1/hints", json=hint_payload)
443444
r.raise_for_status()
444445

446+
def _parse_solution_definition(self) -> tuple[str, str] | None:
447+
solution = self.get("solution", None)
448+
if not solution:
449+
return None
445450

446-
def _resolve_writeup(self) -> Optional[Path]:
447-
writeup_path_candidates = [
448-
self.challenge_directory / "writeup" / "WRITEUP.md",
449-
self.challenge_directory / "writeup" / "writeup.md",
450-
self.challenge_directory / "WRITEUP.md",
451-
self.challenge_directory / "writeup.md",
452-
self.challenge_directory / "writeup" / "README.md",
453-
self.challenge_directory / "writeup" / "readme.md",
454-
]
451+
if type(solution) == str:
452+
return solution, "hidden"
455453

456-
writeup_path = None
457-
for candidate in writeup_path_candidates:
458-
if candidate.exists():
459-
writeup_path = candidate
460-
break
454+
if type(solution) != dict:
455+
click.secho(
456+
"The solution field must be a string path or an object with path and state",
457+
fg="red",
458+
)
459+
return None
460+
461+
solution_path = solution.get("path")
462+
if type(solution_path) != str or not solution_path:
463+
click.secho("The solution object must define a non-empty string path field", fg="red")
464+
return None
465+
466+
solution_state = solution.get("state", "hidden")
467+
if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]:
468+
click.secho("The solution state must be one of: hidden, visible, solved", fg="red")
469+
return None
470+
471+
return solution_path, solution_state
461472

462-
if writeup_path is None:
473+
def _resolve_solution_path(self) -> tuple[Path, str] | None:
474+
parsed_solution = self._parse_solution_definition()
475+
if not parsed_solution:
476+
return None
477+
478+
solution_path_string, solution_state = parsed_solution
479+
solution_path = self.challenge_directory / solution_path_string
480+
if not solution_path.is_file():
463481
click.secho(
464-
f"Could not find a writeup file for challenge {self}!",
482+
f"Solution file '{solution_path_string}' specified, but not found at {solution_path}",
465483
fg="red",
466484
)
467-
return
485+
return None
486+
487+
return solution_path, solution_state
468488

469-
return writeup_path
470-
471489
def _delete_existing_solution(self):
472490
remote_solutions = self.api.get("/api/v1/solutions").json()["data"]
473491
for solution in remote_solutions:
474492
if solution["challenge_id"] == self.challenge_id:
475493
r = self.api.delete(f"/api/v1/solutions/{solution['id']}")
476494
r.raise_for_status()
477495

496+
def _get_existing_solution_id(self) -> int | None:
497+
r = self.api.get("/api/v1/solutions")
498+
r.raise_for_status()
499+
remote_solutions = r.json().get("data") or []
500+
for solution in remote_solutions:
501+
if solution["challenge_id"] == self.challenge_id:
502+
return solution["id"]
503+
return None
504+
478505
def _create_solution(self):
479-
writeup_path = self._resolve_writeup()
506+
resolved_solution = self._resolve_solution_path()
507+
if not resolved_solution:
508+
return
509+
solution_path, solution_state = resolved_solution
480510

481-
if not writeup_path:
482-
click.secho(
483-
f"Failed to create solution for {self}!",
484-
fg="red",
511+
solution_id = self._get_existing_solution_id()
512+
if solution_id is None:
513+
solution_payload_create = {"challenge_id": self.challenge_id, "state": solution_state, "content": ""}
514+
515+
r = self.api.post("/api/v1/solutions", json=solution_payload_create)
516+
r.raise_for_status()
517+
solution_id = r.json()["data"]["id"]
518+
else:
519+
# Keep solution state in sync and clear stale content before rebuilding references.
520+
r = self.api.patch(
521+
f"/api/v1/solutions/{solution_id}",
522+
json={"state": solution_state, "content": ""},
485523
)
486-
return
487-
488-
solution_payload_create = {
489-
"challenge_id": self.challenge_id,
490-
"state": "hidden",
491-
"content": ""
492-
}
524+
r.raise_for_status()
493525

494-
r = self.api.post("/api/v1/solutions", json=solution_payload_create)
495-
r.raise_for_status()
496-
solution_id = r.json()["data"]["id"]
497-
498-
with writeup_path.open("r") as writeup_file:
499-
content = writeup_file.read()
500-
501-
# Find all images in the content (both markdown and HTML formats)
526+
with solution_path.open("r") as solution_file:
527+
content = solution_file.read()
528+
529+
# Find all images in the content (markdown format; ignore html format)
502530
# Markdown format: ![alt text](image_url)
503531
# Returns tuples: (full_match, alt_text, image_path)
504-
markdown_images = re.findall(r'(!\[([^\]]*)\]\(([^\)]+)\))', content)
505-
# HTML format: <img src="..." />
506-
# Returns tuples: (full_match, image_path)
507-
html_images = re.findall(r'(<img[^>]+src=["\']([^"\']+)["\'][^>]*>)', content)
508-
532+
markdown_images = re.findall(r"(!\[([^\]]*)\]\(([^\)]+)\))", content)
533+
509534
# Find all snippet includes (MkDocs style: --8<-- "filename")
510535
# Returns tuples: (full_match, filename)
511536
snippet_includes = re.findall(r'(--8<--\s+["\']([^"\']+)["\'])', content)
512537

513-
514538
for mdx, alt, path in markdown_images:
515-
new_file = ("file", open(writeup_path.parent / path, mode="rb"))
539+
new_file = ("file", open(solution_path.parent / path, mode="rb"))
516540
file_payload = {
517541
"type": "solution",
518542
"solution_id": solution_id,
@@ -527,7 +551,7 @@ def _create_solution(self):
527551

528552
# Process snippet includes (--8<-- "filename")
529553
for full_match, filename in snippet_includes:
530-
snippet_file_path = writeup_path.parent / filename
554+
snippet_file_path = solution_path.parent / filename
531555
if snippet_file_path.exists():
532556
with snippet_file_path.open("r") as snippet_file:
533557
snippet_content = snippet_file.read()
@@ -536,15 +560,7 @@ def _create_solution(self):
536560
else:
537561
log.warning(f"Snippet file not found: {filename}")
538562

539-
# # Log found images for debugging
540-
# if markdown_images:
541-
# print(f"Found {len(markdown_images)} markdown images in writeup")
542-
# if html_images:
543-
# log.debug(f"Found {len(html_images)} HTML images in writeup")
544-
545-
solution_payload_patch = {
546-
"content": content
547-
}
563+
solution_payload_patch = {"content": content}
548564
r = self.api.patch(f"/api/v1/solutions/{solution_id}", json=solution_payload_patch)
549565
r.raise_for_status()
550566

@@ -990,6 +1006,10 @@ def create(self, ignore: tuple[str] = ()) -> None:
9901006
if "next" not in ignore:
9911007
self._set_next(_next)
9921008

1009+
# Add solution
1010+
if "solution" not in ignore:
1011+
self._create_solution()
1012+
9931013
# Bring back the challenge if it's supposed to be visible
9941014
# Either explicitly, or by assuming the default value (possibly because the state is ignored)
9951015
if challenge.get("state", "visible") == "visible" or "state" in ignore:
@@ -1060,6 +1080,35 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool:
10601080
f"Challenge file '{challenge_file}' specified, but not found at {challenge_file_path}"
10611081
)
10621082

1083+
# Check that the optional solution file exists
1084+
solution = self.get("solution", None)
1085+
if solution:
1086+
solution_file = None
1087+
solution_state = "hidden"
1088+
1089+
if type(solution) == str:
1090+
solution_file = solution
1091+
elif type(solution) == dict:
1092+
solution_file = solution.get("path")
1093+
if "visibility" in solution:
1094+
issues["fields"].append("The solution object no longer supports visibility. Use state instead.")
1095+
solution_state = solution.get("state", "hidden")
1096+
1097+
if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]:
1098+
issues["fields"].append("The solution state must be one of: hidden, visible, solved")
1099+
1100+
else:
1101+
issues["fields"].append("The solution field must be a string path or an object with path and state")
1102+
1103+
if type(solution_file) != str or not solution_file:
1104+
issues["fields"].append("The solution object must define a non-empty string path field")
1105+
else:
1106+
solution_file_path = self.challenge_directory / solution_file
1107+
if solution_file_path.is_file() is False:
1108+
issues["files"].append(
1109+
f"Solution file '{solution_file}' specified, but not found at {solution_file_path}"
1110+
)
1111+
10631112
# Check that files don't have a flag in them
10641113
for challenge_file in files:
10651114
challenge_file_path = self.challenge_directory / challenge_file

ctfcli/spec/challenge-example.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ connection_info: nc hostname 12345
4848
# ./writeup/exploit.sh --connection-info "nc hostname 12345"
4949
healthcheck: writeup/exploit.sh
5050

51+
# solution is used to provide a path to the challenge solution document.
52+
# The file path is relative to this challenge.yml file.
53+
# If provided as a string path, ctfcli uploads it as a hidden CTFd solution during sync.
54+
# You can also use an object:
55+
# solution:
56+
# path: writeup/WRITEUP.md
57+
# state: solved # hidden | visible | solved
58+
solution: writeup/WRITEUP.md
59+
5160
# Can be removed if unused
5261
attempts: 5
5362

0 commit comments

Comments
 (0)