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
+35 -3
View File
@@ -5,10 +5,11 @@ GitLab challenges group, works out each challenge's status, category and
difficulty from its `README.md`, and checks that challenges marked as ready difficulty from its `README.md`, and checks that challenges marked as ready
really are. The result goes to: really are. The result goes to:
- **a HedgeDoc note**: an overview of all challenges, refreshed every 30 minutes - **a HedgeDoc note**: an overview of the challenges included in the CTF,
refreshed every 30 minutes
- **`challs.txt` in the deployment repo**: the clone URLs of every challenge - **`challs.txt` in the deployment repo**: the clone URLs of every challenge
ready for infra testing, updated daily included in the CTF and ready for infra testing, updated daily
- **the terminal**: the same overview, for a quick look - **the terminal**: the same overview, plus the repos left out of the CTF
## Requirements ## Requirements
@@ -38,6 +39,34 @@ repo, and permission to push to the branch if it is protected.
Environment variables override the values in `.env`. Environment variables override the values in `.env`.
## Choosing what is in the CTF
[`challenges.yml`](challenges.yml) maps each repo path to its state:
```yaml
challenges:
baby_rsa: included # part of this CTF
static_antivirus: abandoned # dropped for good
some_chall: postponed # left to be finished for another CTF
skip_checks:
whoami: [has_solve_script] # OSINT: there is nothing to script
```
- Only **included** challenges appear in the HedgeDoc note and the counts, and
only they can end up in `challs.txt`.
- **Abandoned** and **postponed** challenges are only listed in the terminal.
- A repo that is **not listed** is also only listed in the terminal, in its own
section, and is not tracked or deployed until somebody decides. The timers run
quietly, so run `./ctf_status.py` by hand to see new repos.
- **`skip_checks`** exempts a repo from individual repo checks, named after their
functions in [`tracker/checks.py`](tracker/checks.py).
Entries for repos that do not exist are flagged under *Needs attention*. If the
file is missing or invalid, the run stops rather than committing an empty
`challs.txt`. The server reads the file from its checkout, so changes must be
pulled there.
## Usage ## Usage
```sh ```sh
@@ -101,6 +130,7 @@ Two kinds of finding are reported:
actually finished: `description.yml` is missing or still the template, its actually finished: `description.yml` is missing or still the template, its
category or flag is wrong, a `deploy_dir` or its `Dockerfile` is missing, or category or flag is wrong, a `deploy_dir` or its `Dockerfile` is missing, or
there is no `solve.py`. A blocked challenge (⛔) is kept out of `challs.txt`. there is no `solve.py`. A blocked challenge (⛔) is kept out of `challs.txt`.
`skip_checks` in `challenges.yml` can exempt a challenge from a check.
Passing every check does not prove that a challenge builds or solves. That is Passing every check does not prove that a challenge builds or solves. That is
what testing is for. what testing is for.
@@ -111,10 +141,12 @@ All rules are in [`tracker/checks.py`](tracker/checks.py), one function each.
``` ```
ctf_status.py command line entry point ctf_status.py command line entry point
challenges.yml what is part of this CTF, and check exceptions
tracker/ tracker/
checks.py the rules: README problems and repo blockers checks.py the rules: README problems and repo blockers
challenge.py reads a README header into a Challenge challenge.py reads a README header into a Challenge
gitlab.py GitLab API access gitlab.py GitLab API access
selection.py reads challenges.yml
overview.py runs parsing and checks over all repos overview.py runs parsing and checks over all repos
terminal.py terminal output terminal.py terminal output
markdown.py HedgeDoc note markdown.py HedgeDoc note
+62
View File
@@ -0,0 +1,62 @@
# Which repos of the challenges group are part of this CTF. Keys are repo paths,
# as in the GitLab URL. Only "included" challenges can end up in challs.txt.
#
# included part of this CTF
# abandoned dropped for good
# postponed left to be finished for another CTF
#
# The HedgeDoc note and the counts only cover included challenges. The others are
# only listed in the terminal summary. A repo that is not listed here is treated
# the same until somebody decides.
challenges:
# crypto
baby_rsa: included
Diffie-Cult-Agreements: included
gram_schmidt: included
intro-lwe: included
Just_go: included
LFSRiously: included
Really-Diffie-Culty-Agreements: included
safe: included
Static-Diffie-Cult-Agreement: included
# pwn
byog: included
haldenueberlauf: included
luaphole: included
top_3_syscalls: included
# web
jwt: included
name-tbd: included
orbiting_around: included
php: included
SATisfaction: included
# rev
break-in: included
mine-turtle: included
moving_target: included
STm8-OF-THE-ART: included
stm8-of-the-art-2: included
# misc
agent-harness: included
you-should-trust-me: included
# osint
concert: included
satellite: included
whoami: included
# not in this CTF
static_antivirus: abandoned
TMPDRAFT_TumAuth: abandoned # never started
mirageos: abandoned # too complicated
arcabe: postponed
dwarf: postponed
resurrection: postponed
the_ART_of_pwning: postponed # unstable
# deliberately unlisted, waiting for the authors to confirm they will finish:
# router_challenge, classifier_rev, the_cure
# Repo checks a challenge does not have to pass: function names from
# REPO_CHECKS in tracker/checks.py, per repo.
skip_checks:
whoami: [has_solve_script] # OSINT: there is nothing to script
+11 -3
View File
@@ -11,6 +11,9 @@ up: description.yml is filled in (not the template), its flag and containers mak
sense, and the deploy dirs and solve.py exist. Otherwise the challenge is shown sense, and the deploy dirs and solve.py exist. Otherwise the challenge is shown
as blocked and kept out of challs.txt. The rules are in tracker/checks.py. as blocked and kept out of challs.txt. The rules are in tracker/checks.py.
challenges.yml says which repos are part of this CTF; only those are counted, put
in the note and deployed. The terminal summary also lists the others.
Config: see .env.example -> .env (chmod 600) Config: see .env.example -> .env (chmod 600)
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,6 +32,7 @@ from tracker.gitlab import GitLab
from tracker.hedgedoc import HedgeDocNote, push_hedgedoc, update_hedgedoc from tracker.hedgedoc import HedgeDocNote, push_hedgedoc, update_hedgedoc
from tracker.markdown import render_hedgedoc from tracker.markdown import render_hedgedoc
from tracker.overview import collect from tracker.overview import collect
from tracker.selection import load_selection
from tracker.terminal import print_terminal from tracker.terminal import print_terminal
@@ -59,7 +63,7 @@ def main() -> None:
help="overwrite an existing HedgeDoc note in place; defaults to " help="overwrite an existing HedgeDoc note in place; defaults to "
"HEDGEDOC_NOTE from the config. This is the one to cron.") "HEDGEDOC_NOTE from the config. This is the one to cron.")
ap.add_argument("--challs", action="store_true", ap.add_argument("--challs", action="store_true",
help="commit the clone URLs of every unblocked Testing Ready+ challenge to " help="commit the clone URLs of every included, unblocked Testing Ready+ challenge to "
f"CHALLS_PATH (default {DEFAULT_CHALLS_PATH!r}) in the " f"CHALLS_PATH (default {DEFAULT_CHALLS_PATH!r}) in the "
"deployment repo CHALLS_REPO_URL, if they changed") "deployment repo CHALLS_REPO_URL, if they changed")
ap.add_argument("--challs-out", metavar="FILE", ap.add_argument("--challs-out", metavar="FILE",
@@ -89,6 +93,10 @@ def main() -> None:
sys.exit(str(e)) sys.exit(str(e))
if args.challs and not cfg.get("CHALLS_REPO_URL"): if args.challs and not cfg.get("CHALLS_REPO_URL"):
sys.exit("--challs needs CHALLS_REPO_URL in the config") sys.exit("--challs needs CHALLS_REPO_URL in the config")
try:
selection = load_selection()
except ValueError as e:
sys.exit(str(e))
if note_url: if note_url:
single_run_lock("hedgedoc") single_run_lock("hedgedoc")
if args.challs: if args.challs:
@@ -96,10 +104,10 @@ def main() -> None:
gl = GitLab(cfg["GITLAB_GROUP_URL"], cfg["GITLAB_TOKEN"]) gl = GitLab(cfg["GITLAB_GROUP_URL"], cfg["GITLAB_TOKEN"])
try: try:
ov = collect(gl) ov = collect(gl, selection)
except (requests.RequestException, RuntimeError) as e: except (requests.RequestException, RuntimeError) as e:
sys.exit(f"reading {cfg['GITLAB_GROUP_URL']} failed: {e}") sys.exit(f"reading {cfg['GITLAB_GROUP_URL']} failed: {e}")
if not ov.challenges and not ov.blank: if not ov.challenges and not ov.blank and not ov.excluded and not ov.undecided:
sys.exit(f"no repos found in {cfg['GITLAB_GROUP_URL']!r}") sys.exit(f"no repos found in {cfg['GITLAB_GROUP_URL']!r}")
md = render_hedgedoc(ov, title, tags) md = render_hedgedoc(ov, title, tags)
+1
View File
@@ -5,6 +5,7 @@
challenge.py turn a README header into a Challenge (status, category, ...) challenge.py turn a README header into a Challenge (status, category, ...)
checks.py decide what is wrong with a challenge, and whether to believe checks.py decide what is wrong with a challenge, and whether to believe
a Testing Ready+ status <- the rules live here 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 overview.py run all of the above over every repo, group and sort the result
terminal.py the terminal summary terminal.py the terminal summary
markdown.py the HedgeDoc note 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": "🟡", STATUS_ICON = {"Planning": "⚪", "PoC Draft": "🟠", "Testing Ready": "🟡",
"Tested": "🔵", "Ready": "🟢", None: "❔"} "Tested": "🔵", "Ready": "🟢", None: "❔"}
BLOCKED_ICON = "⛔" 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"] CATEGORIES = ["crypto", "pwn", "web", "rev", "misc", "osint", "forensic", "hardware"]
DIFFICULTIES = ["intro", "easy", "medium", "hard"] DIFFICULTIES = ["intro", "easy", "medium", "hard"]
DIFF_RE = re.compile(r"intro|easy|medium|hard", re.I) DIFF_RE = re.compile(r"intro|easy|medium|hard", re.I)
@@ -90,7 +92,9 @@ class Challenge:
repo: Repo repo: Repo
name: str = "" name: str = ""
fields: dict = field(default_factory=dict) 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() problems: list = field(default_factory=list) # readme_problems()
blockers: list = field(default_factory=list) # repo_blockers() blockers: list = field(default_factory=list) # repo_blockers()
@@ -131,6 +135,10 @@ class Challenge:
first = CATEGORY_SPLIT_RE.split(raw)[0].strip().lower() first = CATEGORY_SPLIT_RE.split(raw)[0].strip().lower()
return first or None return first or None
@property
def included(self) -> bool:
return self.selection == "included"
@property @property
def claims_testable(self) -> bool: def claims_testable(self) -> bool:
"""The README says Testing Ready or beyond; checks.is_testable() also asks the repo.""" """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: def render_challs(ov: Overview) -> str:
"""One clone URL per line, for everything that is Testing Ready or beyond """One clone URL per line, for everything that is included in this CTF,
and not blocked.""" Testing Ready or beyond and not blocked."""
return "".join(f"{c.repo.ssh_url}\n" for c in ov.testable) 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 * blockers (repo_blockers): the README claims Testing Ready or beyond, but the
repo shows the challenge is not finished. A blocked challenge keeps its 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 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 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 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: def is_testable(c: Challenge) -> bool:
"""Can infra deploy and test it now? The README says Testing Ready or beyond, """Can infra deploy and test it now? It is included in this CTF, the README
and the repo has no blockers.""" says Testing Ready or beyond, and the repo has no blockers."""
return c.claims_testable and not c.blockers return c.included and c.claims_testable and not c.blockers
# ---------------------------------------------------------------- README ---- # ---------------------------------------------------------------- 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]: def status_is_clear(c: Challenge) -> list[str]:
stage, clear = stage_of(c.fields.get("Status", "")) stage, clear = stage_of(c.fields.get("Status", ""))
@@ -196,4 +197,5 @@ def repo_blockers(c: Challenge) -> list[str]:
d = read_description(c.repo) d = read_description(c.repo)
except DescriptionError as e: except DescriptionError as e:
return [str(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 __future__ import annotations
from collections import Counter from collections import Counter
@@ -25,14 +25,14 @@ def render_hedgedoc(ov: Overview, title: str, tags: str = "") -> str:
L += ["---", f"tags: {tags}", "---", ""] L += ["---", f"tags: {tags}", "---", ""]
L += [f"# {title}", "", L += [f"# {title}", "",
f"**Last updated:** {stamp}", "", f"**Last updated:** {stamp}", "",
f"*generated by [challenge_status_tracker]({REPO_URL}) from the README.md of every repo in the " f"*generated by [challenge_status_tracker]({REPO_URL}) from the README.md of every challenge "
f"GitLab challenges group — {len(ov.challenges)} challenges*", "", f"included in this CTF — {len(ov.challenges)} challenges*", "",
" · ".join(f"{STATUS_ICON[s]} **{s or 'unknown'}** {total[s]}" " · ".join(f"{STATUS_ICON[s]} **{s or 'unknown'}** {total[s]}"
for s in stages if total[s] or s), ""] for s in stages if total[s] or s), ""]
ready = ov.testable ready = ov.testable
L += [f"## ✅ Ready for infra testing ({len(ready)} of {len(ov.challenges)})", "", 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*", ""] "these can be deployed and tested against the infrastructure now*", ""]
if ready: if ready:
L += ["| | Challenge | Category | Difficulty | Status | Event | Author | Tester |", 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: for ch in ov.blank:
why = "no README" if ch.repo.readme is None else "README is still the blank template" 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}") 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.*") L.append("*nothing — every README is filled in. suspicious.*")
return "\n".join(L) + "\n" return "\n".join(L) + "\n"
+22 -7
View File
@@ -3,17 +3,21 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor 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 .challenge import CATEGORIES, DIFFICULTIES, STATUS_ORDER, Challenge, difficulty_rank, parse_challenge
from .checks import is_testable, readme_problems, repo_blockers from .checks import is_testable, readme_problems, repo_blockers
from .gitlab import GitLab from .gitlab import GitLab
from .selection import Selection
@dataclass @dataclass
class Overview: class Overview:
challenges: list # repos with a README somebody filled in challenges: list # included repos with a README somebody filled in
blank: list # repos with no README, or an untouched template 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 @property
def categories(self) -> list[str]: def categories(self) -> list[str]:
@@ -56,11 +60,19 @@ class Overview:
return out return out
def collect(gl: GitLab) -> Overview: def collect(gl: GitLab, sel: Selection) -> Overview:
chs, blank = [], [] chs, blank, excluded, undecided, seen = [], [], [], [], set()
for repo in gl.repos(): for repo in gl.repos():
seen.add(repo.path)
c = parse_challenge(repo) 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: for c in chs:
c.problems = readme_problems(c) c.problems = readme_problems(c)
@@ -71,7 +83,10 @@ def collect(gl: GitLab) -> Overview:
with ThreadPoolExecutor(max_workers=8) as pool: with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(check, [c for c in chs if c.claims_testable])) 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: 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 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 from .overview import Overview, icons
BOLD, DIM, RED, YEL, GRN, CYA, RST = ( 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))}" print(f" {cat:<10}" + "".join(f"{x or '·'}{' ' * (13 - (2 * len(x) if x else 1))}"
for x in cells)) 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)}") print(f"\n{col(BOLD)}=== needs attention ==={col(RST)}")
for ch in sorted(ov.challenges, key=lambda c: c.repo.path.lower()): for ch in sorted(ov.challenges, key=lambda c: c.repo.path.lower()):
if ch.problems: if ch.problems:
@@ -60,4 +71,6 @@ def print_terminal(ov: Overview, c: bool) -> None:
for ch in ov.blank: for ch in ov.blank:
why = "no README" if ch.repo.readme is None else "README is still the blank template" 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)}") 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() print()