Skip to content
Open
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
80 changes: 80 additions & 0 deletions ctfcli/core/challenge.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import re
import subprocess
from datetime import datetime, timezone
from os import PathLike
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -67,6 +68,7 @@ class Challenge(dict):
"hints",
"requirements",
"next",
"scheduled_at",
"state",
"version",
]
Expand All @@ -81,6 +83,7 @@ class Challenge(dict):
"files",
"hints",
"requirements",
"scheduled_at",
"state",
"version",
]
Expand Down Expand Up @@ -133,8 +136,67 @@ def is_default_challenge_property(key: str, value: Any) -> bool:
if key == "requirements" and value == {"prerequisites": [], "anonymize": False}:
return True

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

return bool(key == "next" and value is None)

def _parse_scheduled_at(self, value: Any) -> "datetime | None":
# Never assume a timezone for scheduled_at: always expect an explicit offset
if value is None:
return None

if isinstance(value, datetime):
# PyYAML parses unquoted ISO timestamps directly into datetime objects
parsed = value
elif isinstance(value, str):
if not value.strip():
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError as e:
raise InvalidChallengeFile(
f"Challenge file at {self.challenge_file_path} has an invalid 'scheduled_at' value "
f"'{value}': expected an ISO 8601 datetime"
) from e
else:
raise InvalidChallengeFile(
f"Challenge file at {self.challenge_file_path} has an invalid 'scheduled_at' value: "
"expected an ISO 8601 datetime string"
)

if parsed.tzinfo is None:
raise InvalidChallengeFile(
f"Challenge file at {self.challenge_file_path} 'scheduled_at' value '{value}' is missing a "
"timezone. ctfcli does not assume timezones - specify an explicit offset "
"(e.g. 2026-06-15T12:00:00+00:00 for UTC)"
)

return parsed

@staticmethod
def _normalize_scheduled_at(value: Any) -> "str | None":
# CTFd stores and returns scheduled_at as a naive UTC datetime.
# Make the timezone explicit (UTC) on the challenge
if not value:
return None

parsed = value if isinstance(value, datetime) else datetime.fromisoformat(value)
parsed = parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)

return parsed.isoformat()

def _compare_scheduled_at(self, local: Any, remote: Any) -> bool:
# Compare two scheduled_at values by the instant they represent, so that
# equivalent times written with different offsets compare as equal.
local_parsed = self._parse_scheduled_at(local)
remote_parsed = self._parse_scheduled_at(remote)

if local_parsed is None or remote_parsed is None:
return local_parsed == remote_parsed

return local_parsed.astimezone(timezone.utc) == remote_parsed.astimezone(timezone.utc)

@staticmethod
def clone(config, remote_challenge):
name = remote_challenge["name"]
Expand Down Expand Up @@ -318,6 +380,11 @@ def _get_initial_challenge_payload(self, ignore: tuple[str] = ()) -> dict:
if "connection_info" not in ignore:
challenge_payload["connection_info"] = challenge.get("connection_info", None)

if "scheduled_at" not in ignore:
# _parse_scheduled_at validates the timezone is explicit and raises otherwise
parsed_scheduled_at = self._parse_scheduled_at(challenge.get("scheduled_at"))
challenge_payload["scheduled_at"] = parsed_scheduled_at.isoformat() if parsed_scheduled_at else None

if "logic" not in ignore and challenge.get("logic"):
challenge_payload["logic"] = challenge.get("logic") or "any"

Expand Down Expand Up @@ -758,6 +825,9 @@ def _normalize_challenge(self, challenge_data: dict[str, Any]):
if key in challenge_data:
challenge[key] = challenge_data[key]

# CTFd returns scheduled_at as a naive UTC datetime - make the timezone explicit
challenge["scheduled_at"] = self._normalize_scheduled_at(challenge_data.get("scheduled_at"))

challenge["description"] = challenge_data["description"].strip().replace("\r\n", "\n").replace("\t", "")
challenge["attribution"] = challenge_data.get("attribution", "")
if challenge["attribution"]:
Expand Down Expand Up @@ -1118,6 +1188,13 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool:
if challenge.get(field) is None:
issues["fields"].append(f"challenge.yml is missing required field: {field}")

# Check that scheduled_at, if present, carries an explicit timezone
if challenge.get("scheduled_at") is not None:
try:
self._parse_scheduled_at(challenge.get("scheduled_at"))
except InvalidChallengeFile as e:
issues["fields"].append(str(e))

# Check that the image field and Dockerfile match
if (self.challenge_directory / "Dockerfile").is_file() and challenge.get("image", "") != ".":
issues["dockerfile"].append("Dockerfile exists but image field does not point to it")
Expand Down Expand Up @@ -1295,6 +1372,9 @@ def verify(self, ignore: tuple[str] = ()) -> bool:
if key == "next" and self._compare_challenge_next(challenge[key], normalized_challenge[key]):
continue

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

click.secho(
f"{key} comparison failed.",
fg="yellow",
Expand Down
7 changes: 7 additions & 0 deletions ctfcli/spec/challenge-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ requirements:
# if you want to remove or disable it.
next: null

# scheduled_at schedules a timed release: a visible challenge stays hidden from
# players until this moment passes. Omit or set to null for no schedule.
# Must be an ISO 8601 datetime WITH an explicit timezone offset
# scheduled_at: "2026-06-15T12:00:00+00:00" # UTC
# scheduled_at: "2026-06-15T14:00:00+02:00" # CEST
# scheduled_at: 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
Loading