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>
160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
"""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
|