Skip to content

Commit b786fc2

Browse files
Merge pull request #1 from Cleverconnection/codex/extend-ctfcli-to-support-docker-challenge-fields
Support Docker plugin challenge fields and write-ups
2 parents d98ab55 + 7cef2fc commit b786fc2

File tree

3 files changed

+226
-33
lines changed

3 files changed

+226
-33
lines changed

ctfcli/core/challenge.py

Lines changed: 127 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,64 @@ def _validate_files(self):
263263
if not (self.challenge_directory / challenge_file).exists():
264264
raise InvalidChallengeFile(f"File {challenge_file} could not be loaded")
265265

266+
@staticmethod
267+
def _coerce_int(value: Any) -> Any:
268+
if isinstance(value, int):
269+
return value
270+
271+
if isinstance(value, str):
272+
stripped_value = value.strip()
273+
if stripped_value.isdigit() or (
274+
stripped_value.startswith("-") and stripped_value[1:].isdigit()
275+
):
276+
try:
277+
return int(stripped_value)
278+
except ValueError:
279+
return value
280+
281+
return value
282+
283+
def _extract_container_fields(self, ignore: Tuple[str, ...] = ()) -> Dict[str, Any]:
284+
container_keys = {
285+
"image",
286+
"port",
287+
"command",
288+
"volumes",
289+
"ctype",
290+
"initial",
291+
"decay",
292+
"minimum",
293+
}
294+
295+
numeric_keys = {"port", "initial", "decay", "minimum"}
296+
297+
container_payload: Dict[str, Any] = {}
298+
sources: List[Dict[str, Any]] = []
299+
300+
type_data = self.get("type_data")
301+
if isinstance(type_data, dict):
302+
sources.append(type_data)
303+
304+
if "extra" not in ignore:
305+
extra = self.get("extra")
306+
if isinstance(extra, dict):
307+
sources.append(extra)
308+
309+
sources.append(self)
310+
311+
for source in sources:
312+
for key in container_keys:
313+
if key not in source or source[key] in (None, ""):
314+
continue
315+
316+
value = source[key]
317+
if key in numeric_keys:
318+
value = self._coerce_int(value)
319+
320+
container_payload[key] = value
321+
322+
return container_payload
323+
266324
def _get_initial_challenge_payload(self, ignore: Tuple[str] = ()) -> Dict:
267325
challenge = self
268326
challenge_payload = {
@@ -275,28 +333,26 @@ def _get_initial_challenge_payload(self, ignore: Tuple[str] = ()) -> Dict:
275333
"state": "hidden",
276334
}
277335

278-
# Some challenge types (e.g., dynamic) override value.
279-
# We can't send it to CTFd because we don't know the current value
280-
if challenge.get("value", None) is not None:
281-
# if value is an int as string, cast it
282-
if type(challenge["value"]) == str and challenge["value"].isdigit():
283-
challenge_payload["value"] = int(challenge["value"])
284-
285-
if type(challenge["value"] == int):
286-
challenge_payload["value"] = challenge["value"]
336+
value = challenge.get("value", None)
337+
if value is not None:
338+
challenge_payload["value"] = self._coerce_int(value)
287339

288340
if "attempts" not in ignore:
289341
challenge_payload["max_attempts"] = challenge.get("attempts", 0)
290342

291343
if "connection_info" not in ignore:
292344
challenge_payload["connection_info"] = challenge.get("connection_info", None)
293345

294-
if "logic" not in ignore:
295-
if challenge.get("logic"):
296-
challenge_payload["logic"] = challenge.get("logic") or "any"
346+
if "logic" not in ignore and challenge.get("logic"):
347+
challenge_payload["logic"] = challenge.get("logic") or "any"
297348

298349
if "extra" not in ignore:
299-
challenge_payload = {**challenge_payload, **challenge.get("extra", {})}
350+
extra = challenge.get("extra", {})
351+
if isinstance(extra, dict):
352+
challenge_payload = {**challenge_payload, **extra}
353+
354+
container_fields = self._extract_container_fields(ignore=ignore)
355+
challenge_payload.update(container_fields)
300356

301357
return challenge_payload
302358

@@ -354,6 +410,52 @@ def _create_tags(self):
354410
)
355411
r.raise_for_status()
356412

413+
def _upload_writeup(self) -> None:
414+
writeup_path = self.challenge_directory / "WRITEUP.md"
415+
416+
if not writeup_path.exists() or not writeup_path.is_file():
417+
return
418+
419+
try:
420+
content = writeup_path.read_text(encoding="utf-8")
421+
except Exception as exc: # pragma: no cover - unexpected IO error
422+
log.warning("Failed to read write-up for challenge %s: %s", self.get("name"), exc)
423+
return
424+
425+
payload = {"challenge": self.challenge_id, "content": content, "publish": True}
426+
427+
try:
428+
response = self.api.post("/api/v1/writeups", json=payload)
429+
except Exception as exc: # pragma: no cover - network error
430+
log.warning(
431+
"Failed to upload write-up for challenge %s: %s", self.get("name", self.challenge_id), exc
432+
)
433+
return
434+
435+
if not response.ok:
436+
log.warning(
437+
"Failed to upload write-up for challenge %s: (%s) %s",
438+
self.get("name", self.challenge_id),
439+
response.status_code,
440+
response.text,
441+
)
442+
return
443+
444+
try:
445+
data = response.json()
446+
except ValueError: # pragma: no cover - unexpected response body
447+
log.warning(
448+
"Failed to parse write-up upload response for challenge %s", self.get("name", self.challenge_id)
449+
)
450+
return
451+
452+
if not data.get("success", True):
453+
log.warning(
454+
"CTFd rejected write-up upload for challenge %s: %s",
455+
self.get("name", self.challenge_id),
456+
data,
457+
)
458+
357459
def _delete_file(self, remote_location: str):
358460
remote_files = self.api.get("/api/v1/files?type=challenge").json()["data"]
359461

@@ -557,6 +659,14 @@ def _normalize_challenge(self, challenge_data: Dict[str, Any]):
557659
"state",
558660
"connection_info",
559661
"logic",
662+
"image",
663+
"port",
664+
"command",
665+
"volumes",
666+
"ctype",
667+
"initial",
668+
"decay",
669+
"minimum",
560670
]
561671
for key in copy_keys:
562672
if key in challenge_data:
@@ -568,13 +678,6 @@ def _normalize_challenge(self, challenge_data: Dict[str, Any]):
568678
challenge["attribution"] = challenge["attribution"].strip().replace("\r\n", "\n").replace("\t", "")
569679
challenge["attempts"] = challenge_data["max_attempts"]
570680

571-
for key in ["initial", "decay", "minimum"]:
572-
if key in challenge_data:
573-
if "extra" not in challenge:
574-
challenge["extra"] = {}
575-
576-
challenge["extra"][key] = challenge_data[key]
577-
578681
# Add flags
579682
r = self.api.get(f"/api/v1/challenges/{self.challenge_id}/flags")
580683
r.raise_for_status()
@@ -696,6 +799,8 @@ def sync(self, ignore: Tuple[str] = ()) -> None:
696799
click.secho(f"Failed to sync challenge: ({r.status_code}) {r.text}", fg="red")
697800
r.raise_for_status()
698801

802+
self._upload_writeup()
803+
699804
# Update flags
700805
if "flags" not in ignore:
701806
self._delete_existing_flags()
@@ -824,6 +929,8 @@ def create(self, ignore: Tuple[str] = ()) -> None:
824929

825930
self.challenge_id = r.json()["data"]["id"]
826931

932+
self._upload_writeup()
933+
827934
# Create flags
828935
if challenge.get("flags") and "flags" not in ignore:
829936
self._create_flags()

ctfcli/spec/challenge-example.yml

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,11 @@ type: standard
1212

1313
# The extra field provides additional fields for data during the install/sync commands/
1414
# Fields in extra can be used to supply additional information for other challenge types
15-
# For example the follow extra field is for dynamic challenges. To use these following
16-
# extra fields, set the type to "dynamic" and uncomment the "extra" section below
17-
# extra:
18-
# initial: 500
19-
# decay: 100
20-
# minimum: 50
15+
# Many deployments (including the CTFd Docker plugin) accept dynamic scoring fields
16+
# like the following at the top level or under "extra"/"type_data":
17+
# initial: 500
18+
# decay: 100
19+
# minimum: 50
2120

2221
# Settings used for Dockerfile deployment
2322
# If not used, remove or set to null
@@ -26,6 +25,14 @@ type: standard
2625
# Follow Docker best practices and assign a tag
2726
image: null
2827

28+
# Additional container configuration fields supported by the CTFd Docker plugin
29+
# can optionally be provided here or within the extra/type_data sections.
30+
# port: 8080
31+
# command: ./start.sh
32+
# volumes:
33+
# - /host/path:/container/path
34+
# ctype: docker
35+
2936
# Specify a protocol that should be used to connect to the running image
3037
# For example if the image is a website you can specify http or https
3138
# Otherwise you can specify tcp

tests/core/test_challenge.py

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,9 +1360,90 @@ def mock_post(*args, **kwargs):
13601360
[call("/api/v1/challenges/3", json={"state": "visible"}), call().raise_for_status()]
13611361
)
13621362
mock_api.post.assert_called_once_with("/api/v1/challenges", json=expected_challenge_payload)
1363-
mock_api.get.assert_not_called()
1364-
mock_api.delete.assert_not_called()
1363+
mock_api.get.assert_not_called()
1364+
mock_api.delete.assert_not_called()
1365+
1366+
1367+
class TestContainerChallengePayload(unittest.TestCase):
1368+
minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml"
1369+
1370+
def test_initial_payload_includes_container_fields_from_root(self):
1371+
overrides = {
1372+
"image": "library/test-challenge:latest",
1373+
"port": "8080",
1374+
"command": "/run.sh",
1375+
"volumes": ["/tmp:/app"],
1376+
"ctype": "container",
1377+
"initial": "100",
1378+
"decay": "5",
1379+
"minimum": "10",
1380+
}
1381+
1382+
challenge = Challenge(self.minimal_challenge, overrides)
1383+
payload = challenge._get_initial_challenge_payload()
1384+
1385+
self.assertEqual(payload["image"], "library/test-challenge:latest")
1386+
self.assertEqual(payload["port"], 8080)
1387+
self.assertEqual(payload["command"], "/run.sh")
1388+
self.assertEqual(payload["volumes"], ["/tmp:/app"])
1389+
self.assertEqual(payload["ctype"], "container")
1390+
self.assertEqual(payload["initial"], 100)
1391+
self.assertEqual(payload["decay"], 5)
1392+
self.assertEqual(payload["minimum"], 10)
1393+
1394+
def test_initial_payload_merges_container_fields_from_extra_and_type_data(self):
1395+
overrides = {
1396+
"extra": {"port": "9000", "command": "start"},
1397+
"type_data": {"volumes": ["/var:/srv"], "ctype": "docker"},
1398+
}
1399+
1400+
challenge = Challenge(self.minimal_challenge, overrides)
1401+
payload = challenge._get_initial_challenge_payload()
1402+
1403+
self.assertEqual(payload["port"], 9000)
1404+
self.assertEqual(payload["command"], "start")
1405+
self.assertEqual(payload["volumes"], ["/var:/srv"])
1406+
self.assertEqual(payload["ctype"], "docker")
1407+
1408+
def test_root_container_fields_override_nested_values(self):
1409+
overrides = {
1410+
"port": 7000,
1411+
"extra": {"port": "6000"},
1412+
"type_data": {"port": "5000"},
1413+
}
13651414

1415+
challenge = Challenge(self.minimal_challenge, overrides)
1416+
payload = challenge._get_initial_challenge_payload()
1417+
1418+
self.assertEqual(payload["port"], 7000)
1419+
1420+
1421+
class TestWriteupUpload(unittest.TestCase):
1422+
minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml"
1423+
1424+
@mock.patch("ctfcli.core.challenge.API")
1425+
def test_upload_writeup_posts_content(self, mock_api_constructor: MagicMock):
1426+
challenge = Challenge(self.minimal_challenge)
1427+
challenge.challenge_id = 123
1428+
1429+
writeup_path = challenge.challenge_directory / "WRITEUP.md"
1430+
writeup_path.write_text("# Sample Write-up", encoding="utf-8")
1431+
1432+
mock_api = mock_api_constructor.return_value
1433+
mock_response = MagicMock()
1434+
mock_response.ok = True
1435+
mock_response.json.return_value = {"success": True}
1436+
mock_api.post.return_value = mock_response
1437+
1438+
try:
1439+
challenge._upload_writeup()
1440+
finally:
1441+
writeup_path.unlink(missing_ok=True)
1442+
1443+
mock_api.post.assert_called_once_with(
1444+
"/api/v1/writeups",
1445+
json={"challenge": 123, "content": "# Sample Write-up", "publish": True},
1446+
)
13661447

13671448
class TestLintChallenge(unittest.TestCase):
13681449
minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml"
@@ -1737,6 +1818,9 @@ def test_normalize_fetches_and_normalizes_challenge(self, mock_api_constructor:
17371818
"description": "Test Description",
17381819
"attribution": "Test Attribution",
17391820
"attempts": 5,
1821+
"initial": 100,
1822+
"decay": 10,
1823+
"minimum": 10,
17401824
"flags": [
17411825
"flag{test-flag}",
17421826
{"content": "flag{test-static}", "type": "static", "data": "case_insensitive"},
@@ -1747,11 +1831,6 @@ def test_normalize_fetches_and_normalizes_challenge(self, mock_api_constructor:
17471831
"topics": ["topic-1", "topic-2"],
17481832
"next": None,
17491833
"requirements": {"prerequisites": ["First Test Challenge", "Other Test Challenge"], "anonymize": False},
1750-
"extra": {
1751-
"initial": 100,
1752-
"decay": 10,
1753-
"minimum": 10,
1754-
},
17551834
},
17561835
normalized_data,
17571836
)

0 commit comments

Comments
 (0)