Files
challenge_status_tracker/tracker/challenge.py
T
Casually9790andClaude Opus 5.5 e00f40eaf4 Split ctf_status.py into a tracker package, config from .env only
ctf_status.py is now just the command line; the work lives in tracker/.
The rules that decide whether a challenge is believed are in
tracker/checks.py, one function per rule, listed in README_CHECKS and
REPO_CHECKS. Output is unchanged.

Read the config from .env only (ctf_status.env.example -> .env.example),
let single_run_lock() hold its own lock files, and rewrite the README as
a short overview of what the repo does and how to run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:40:24 +02:00

160 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Reading a challenge's README header.
Only the template's header block is parsed:
### Challenge Name
- Category: crypto
- Difficulty: medium
- Status: Testing Ready
...
People fill it in loosely, so the values are normalised here. Whether they are
good enough is decided in checks.py.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from .gitlab import Repo
FIELDS = ("Category", "Difficulty", "Event", "Author", "Tester", "Status")
HEADING_RE = re.compile(r"^\s{0,3}#{1,4}\s+(.+?)\s*$", re.M)
FIELD_RE = re.compile(r"^\s*[-*]\s*\**(?P<key>[A-Za-z ]+?)\**\s*:\s*(?P<val>.*?)\s*$", re.M)
# a value the author never touched: "[crypto, pwn, ...]" or "[Author Name]"
PLACEHOLDER_RE = re.compile(r"^\[.*\]$")
NO_TESTER_RE = re.compile(r"^(\?+|none|tester name|not found\b.*|tbd)(\s.*)?$", re.I)
STATUS_ORDER = ["Planning", "PoC Draft", "Testing Ready", "Tested", "Ready"]
STATUS_RE = re.compile(r"testing\s*ready|tested|ready|poc(?:\s*draft)?|planning", re.I)
STATUS_ICON = {"Planning": "⚪", "PoC Draft": "🟠", "Testing Ready": "🟡",
"Tested": "🔵", "Ready": "🟢", None: "❔"}
BLOCKED_ICON = "⛔"
CATEGORIES = ["crypto", "pwn", "web", "rev", "misc", "osint", "forensic", "hardware"]
DIFFICULTIES = ["intro", "easy", "medium", "hard"]
DIFF_RE = re.compile(r"intro|easy|medium|hard", re.I)
DIFF_RANGE_RE = re.compile(r"(intro|easy|medium|hard)\s*(?:-|–|/|\bto\b)\s*(intro|easy|medium|hard)", re.I)
CATEGORY_SPLIT_RE = re.compile(r"[,/&+]| and ")
def clean(v: str) -> str:
v = re.sub(r"\\+([\[\]_*])", r"\1", v) # markdown escapes, sometimes doubled
return v.replace("**", "").strip()
def is_placeholder(v: str) -> bool:
v = clean(v)
return not v or bool(PLACEHOLDER_RE.match(v))
def stage_of(raw: str) -> tuple[str | None, bool]:
"""Map a free-text status onto STATUS_ORDER -> (stage, clear).
"Tested with h4tum-chall-format" is clearly Tested. "Planning to PoC Draft"
or a half-deleted option list is not; we take the least advanced stage it
names, so nothing looks further along than it is, and call it unclear."""
if is_placeholder(raw):
return None, False
found = set()
for m in STATUS_RE.finditer(raw):
w = m.group(0).lower()
found.add("Testing Ready" if w.startswith("testing") else
"PoC Draft" if w.startswith("poc") else w.title())
if not found:
return None, False
return min(found, key=STATUS_ORDER.index), len(found) == 1
def difficulty_of(raw: str) -> str | None:
""""medium/hard", "medium-hard", "easy to medium" -> a range "medium-hard"."""
if is_placeholder(raw):
return None
m = DIFF_RANGE_RE.search(raw)
if m:
ends = sorted({m.group(1).lower(), m.group(2).lower()}, key=DIFFICULTIES.index)
return "-".join(ends) if len(ends) > 1 else ends[0]
# prose like "currently medium, maybe also an easy and a hard version": the first one
m = DIFF_RE.search(raw)
return m.group(0).lower() if m else None
def difficulty_rank(d: str | None) -> float:
if not d:
return 99
ends = d.split("-")
return sum(DIFFICULTIES.index(e) for e in ends) / len(ends)
@dataclass
class Challenge:
repo: Repo
name: str = ""
fields: dict = field(default_factory=dict)
# filled in by overview.collect(), see checks.py
problems: list = field(default_factory=list) # readme_problems()
blockers: list = field(default_factory=list) # repo_blockers()
def get(self, key: str, dash: str = "—") -> str:
v = clean(self.fields.get(key, ""))
return dash if is_placeholder(v) else v
# -- derived -----------------------------------------------------------
@property
def named(self) -> bool:
return bool(self.name) and not is_placeholder(self.name)
@property
def title(self) -> str:
return self.name if self.named else self.repo.path
@property
def touched(self) -> bool:
"""Did anyone put *any* real content in the README template?"""
return self.named or any(not is_placeholder(self.fields.get(f, "")) for f in FIELDS)
@property
def status_raw(self) -> str:
return self.get("Status")
@property
def stage(self) -> str | None:
return stage_of(self.fields.get("Status", ""))[0]
@property
def difficulty(self) -> str | None:
return difficulty_of(self.fields.get("Difficulty", ""))
@property
def category(self) -> str | None:
"""The first category named; "rev, misc" counts as rev."""
raw = self.get("Category", "")
first = CATEGORY_SPLIT_RE.split(raw)[0].strip().lower()
return first or None
@property
def claims_testable(self) -> bool:
"""The README says Testing Ready or beyond; checks.is_testable() also asks the repo."""
return bool(self.stage) and STATUS_ORDER.index(self.stage) >= STATUS_ORDER.index("Testing Ready")
@property
def icon(self) -> str:
return BLOCKED_ICON if self.blockers else STATUS_ICON[self.stage]
@property
def has_tester(self) -> bool:
t = self.get("Tester", "")
return bool(t) and not NO_TESTER_RE.match(t)
def parse_challenge(repo: Repo) -> Challenge:
c = Challenge(repo=repo)
text = repo.readme or ""
m = HEADING_RE.search(text)
if m:
c.name = clean(m.group(1))
for fm in FIELD_RE.finditer(text):
key = fm.group("key").strip().title()
if key in FIELDS and key not in c.fields:
c.fields[key] = fm.group("val")
return c