Files

168 lines
5.9 KiB
Python
Raw Permalink 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 = "⛔"
# what challenges.yml can say about a repo, and how the reports call it
SELECTIONS = {"included": "included", "abandoned": "abandoned", "postponed": "left for another CTF"}
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 selection.py and checks.py
selection: str | None = None # None: not in challenges.yml
skip_checks: list = field(default_factory=list)
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 included(self) -> bool:
return self.selection == "included"
@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