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>
This commit is contained in:
2026-10-20 19:40:24 +02:00
co-authored by Claude Opus 5.5
parent 4c6cb21cb8
commit e00f40eaf4
15 changed files with 1098 additions and 984 deletions
+159
View File
@@ -0,0 +1,159 @@
"""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