added filter to decide what challenges to include

This commit is contained in:
2026-11-05 15:42:52 +01:00
parent 4fac6ed86d
commit 09849d0ef6
11 changed files with 214 additions and 28 deletions
+1
View File
@@ -5,6 +5,7 @@
challenge.py turn a README header into a Challenge (status, category, ...)
checks.py decide what is wrong with a challenge, and whether to believe
a Testing Ready+ status <- the rules live here
selection.py read challenges.yml: what is part of this CTF, check exceptions
overview.py run all of the above over every repo, group and sort the result
terminal.py the terminal summary
markdown.py the HedgeDoc note
+9 -1
View File
@@ -30,6 +30,8 @@ STATUS_RE = re.compile(r"testing\s*ready|tested|ready|poc(?:\s*draft)?|planning"
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)
@@ -90,7 +92,9 @@ class Challenge:
repo: Repo
name: str = ""
fields: dict = field(default_factory=dict)
# filled in by overview.collect(), see checks.py
# 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()
@@ -131,6 +135,10 @@ class Challenge:
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."""
+2 -2
View File
@@ -5,8 +5,8 @@ from .overview import Overview
def render_challs(ov: Overview) -> str:
"""One clone URL per line, for everything that is Testing Ready or beyond
and not blocked."""
"""One clone URL per line, for everything that is included in this CTF,
Testing Ready or beyond and not blocked."""
return "".join(f"{c.repo.ssh_url}\n" for c in ov.testable)
+8 -6
View File
@@ -8,7 +8,8 @@ There are two kinds of finding:
* blockers (repo_blockers): the README claims Testing Ready or beyond, but the
repo shows the challenge is not finished. A blocked challenge keeps its
status in the counts, but is left out of "ready for infra testing" and out of
challs.txt, so it does not get deployed.
challs.txt, so it does not get deployed. challenges.yml can exempt a challenge
from individual repo checks.
Every rule is one small function that returns what it found wrong, as a list
of messages (empty: nothing wrong). README_CHECKS and REPO_CHECKS list the rules
@@ -28,13 +29,13 @@ from .gitlab import Repo
def is_testable(c: Challenge) -> bool:
"""Can infra deploy and test it now? The README says Testing Ready or beyond,
and the repo has no blockers."""
return c.claims_testable and not c.blockers
"""Can infra deploy and test it now? It is included in this CTF, the README
says Testing Ready or beyond, and the repo has no blockers."""
return c.included and c.claims_testable and not c.blockers
# ---------------------------------------------------------------- README ----
# Run on every challenge whose README somebody filled in.
# Run on every included challenge whose README somebody filled in.
def status_is_clear(c: Challenge) -> list[str]:
stage, clear = stage_of(c.fields.get("Status", ""))
@@ -196,4 +197,5 @@ def repo_blockers(c: Challenge) -> list[str]:
d = read_description(c.repo)
except DescriptionError as e:
return [str(e)]
return [msg for check in REPO_CHECKS for msg in check(c, d)]
return [msg for check in REPO_CHECKS if check.__name__ not in c.skip_checks
for msg in check(c, d)]
+7 -5
View File
@@ -1,4 +1,4 @@
"""The HedgeDoc note, as markdown."""
"""The HedgeDoc note, as markdown. It only covers challenges included in this CTF."""
from __future__ import annotations
from collections import Counter
@@ -25,14 +25,14 @@ def render_hedgedoc(ov: Overview, title: str, tags: str = "") -> str:
L += ["---", f"tags: {tags}", "---", ""]
L += [f"# {title}", "",
f"**Last updated:** {stamp}", "",
f"*generated by [challenge_status_tracker]({REPO_URL}) from the README.md of every repo in the "
f"GitLab challenges group — {len(ov.challenges)} challenges*", "",
f"*generated by [challenge_status_tracker]({REPO_URL}) from the README.md of every challenge "
f"included in this CTF — {len(ov.challenges)} challenges*", "",
" · ".join(f"{STATUS_ICON[s]} **{s or 'unknown'}** {total[s]}"
for s in stages if total[s] or s), ""]
ready = ov.testable
L += [f"## ✅ Ready for infra testing ({len(ready)} of {len(ov.challenges)})", "",
"*every challenge at Testing Ready or beyond whose repo backs that up — "
"*every included challenge at Testing Ready or beyond whose repo backs that up — "
"these can be deployed and tested against the infrastructure now*", ""]
if ready:
L += ["| | Challenge | Category | Difficulty | Status | Event | Author | Tester |",
@@ -111,6 +111,8 @@ def render_hedgedoc(ov: Overview, title: str, tags: str = "") -> str:
for ch in ov.blank:
why = "no README" if ch.repo.readme is None else "README is still the blank template"
L.append(f"- [{ch.repo.path}]({ch.repo.web_url}): {why}")
if not issues and not ov.blank:
for path in ov.unknown:
L.append(f"- {path}: in challenges.yml, but no such repo")
if not issues and not ov.blank and not ov.unknown:
L.append("*nothing — every README is filled in. suspicious.*")
return "\n".join(L) + "\n"
+22 -7
View File
@@ -3,17 +3,21 @@ from __future__ import annotations
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from dataclasses import dataclass, field
from .challenge import CATEGORIES, DIFFICULTIES, STATUS_ORDER, Challenge, difficulty_rank, parse_challenge
from .checks import is_testable, readme_problems, repo_blockers
from .gitlab import GitLab
from .selection import Selection
@dataclass
class Overview:
challenges: list # repos with a README somebody filled in
blank: list # repos with no README, or an untouched template
challenges: list # included repos with a README somebody filled in
blank: list # included repos with no README, or an untouched template
excluded: list = field(default_factory=list) # abandoned or postponed in challenges.yml
undecided: list = field(default_factory=list) # repos not in challenges.yml
unknown: list = field(default_factory=list) # repo paths in challenges.yml that do not exist
@property
def categories(self) -> list[str]:
@@ -56,11 +60,19 @@ class Overview:
return out
def collect(gl: GitLab) -> Overview:
chs, blank = [], []
def collect(gl: GitLab, sel: Selection) -> Overview:
chs, blank, excluded, undecided, seen = [], [], [], [], set()
for repo in gl.repos():
seen.add(repo.path)
c = parse_challenge(repo)
(chs if c.touched else blank).append(c)
c.selection = sel.states.get(repo.path)
c.skip_checks = sel.skip_checks.get(repo.path, [])
if not c.selection:
undecided.append(c)
elif not c.included:
excluded.append(c)
else:
(chs if c.touched else blank).append(c)
for c in chs:
c.problems = readme_problems(c)
@@ -71,7 +83,10 @@ def collect(gl: GitLab) -> Overview:
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(check, [c for c in chs if c.claims_testable]))
return Overview(chs, sorted(blank, key=lambda c: c.repo.path.lower()))
return Overview(chs, sorted(blank, key=lambda c: c.repo.path.lower()),
sorted(excluded, key=lambda c: c.repo.path.lower()),
sorted(undecided, key=lambda c: c.repo.path.lower()),
sorted((set(sel.states) | set(sel.skip_checks)) - seen))
def icons(chs: list) -> str:
+43
View File
@@ -0,0 +1,43 @@
"""challenges.yml: which repos are part of this CTF, and their check exceptions."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import yaml
from .challenge import SELECTIONS
from .checks import REPO_CHECKS
SELECTION_FILE = Path(__file__).parent.parent / "challenges.yml" # next to ctf_status.py
@dataclass
class Selection:
states: dict # repo path -> one of SELECTIONS
skip_checks: dict # repo path -> names of REPO_CHECKS it need not pass
def load_selection(path: Path = SELECTION_FILE) -> Selection:
"""Raises ValueError on anything off. A broken file must stop the run: without
it nothing counts as included, and challs.txt would be committed empty."""
try:
doc = yaml.safe_load(path.read_text())
except (OSError, yaml.YAMLError) as e:
raise ValueError(f"cannot read {path.name}: {e}") from None
states = doc.get("challenges") if isinstance(doc, dict) else None
if not states or not isinstance(states, dict):
raise ValueError(f"{path.name} has no `challenges:` mapping")
bad = [f"{k}: {v}" for k, v in states.items() if v not in SELECTIONS]
if bad:
raise ValueError(f"{path.name}: expected one of {', '.join(SELECTIONS)}, got " + ", ".join(bad))
skip = doc.get("skip_checks") or {}
if not isinstance(skip, dict) or not all(isinstance(v, list) for v in skip.values()):
raise ValueError(f"{path.name}: `skip_checks:` must map repos to lists of checks")
known = {check.__name__ for check in REPO_CHECKS}
bad = sorted({str(n) for names in skip.values() for n in names} - known)
if bad:
raise ValueError(f"{path.name}: no such repo check: " + ", ".join(bad))
return Selection({str(k): v for k, v in states.items()},
{str(k): [str(n) for n in v] for k, v in skip.items()})
+14 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import Counter
from .challenge import BLOCKED_ICON, STATUS_ICON, STATUS_ORDER
from .challenge import BLOCKED_ICON, SELECTIONS, STATUS_ICON, STATUS_ORDER
from .overview import Overview, icons
BOLD, DIM, RED, YEL, GRN, CYA, RST = (
@@ -52,6 +52,17 @@ def print_terminal(ov: Overview, c: bool) -> None:
print(f" {cat:<10}" + "".join(f"{x or '·'}{' ' * (13 - (2 * len(x) if x else 1))}"
for x in cells))
if ov.excluded:
print(f"\n{col(BOLD)}=== not in this CTF ({len(ov.excluded)}) ==={col(RST)}")
for ch in ov.excluded:
print(f" {ch.title[:34]:<35}{col(DIM)}{SELECTIONS[ch.selection]}{col(RST)}")
if ov.undecided:
print(f"\n{col(BOLD)}{col(YEL)}=== not in challenges.yml: not tracked or deployed "
f"({len(ov.undecided)}) ==={col(RST)}")
for ch in ov.undecided:
print(f" {ch.title[:34]:<35}{col(DIM)}{ch.repo.path}{col(RST)}")
print(f"\n{col(BOLD)}=== needs attention ==={col(RST)}")
for ch in sorted(ov.challenges, key=lambda c: c.repo.path.lower()):
if ch.problems:
@@ -60,4 +71,6 @@ def print_terminal(ov: Overview, c: bool) -> None:
for ch in ov.blank:
why = "no README" if ch.repo.readme is None else "README is still the blank template"
print(f" {col(RED)}{ch.repo.path:<32}{col(RST)} {col(DIM)}{why}{col(RST)}")
for path in ov.unknown:
print(f" {col(RED)}{path:<32}{col(RST)} {col(DIM)}in challenges.yml, but no such repo{col(RST)}")
print()