Initial commit

This commit is contained in:
sudoBash418 2022-09-04 22:04:36 -06:00
commit 136d723510
Signed by: sudoBash418
GPG key ID: A75052AB46BFA091
21 changed files with 1259 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Python
**/__pycache__/
**/.mypy_cache/
/.venv/
# IDEs/Editors
/.vscode/

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# CTF Challenge ToolKit
A CLI tool for automating challenge management and deployment.

12
cctk/__init__.py Normal file
View file

@ -0,0 +1,12 @@
# handle toml import here for other modules
import sys
if sys.version_info >= (3, 11):
# Python >= 3.11 (stdlib module)
import tomllib
else:
# Python <= 3.10 (third-party dependency)
import tomli as tomllib
# import CLI commands
from . import commands

7
cctk/__main__.py Normal file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env python3
from .commands import root as entrypoint
# run the main Click entrypoint
entrypoint()

52
cctk/commands/__init__.py Normal file
View file

@ -0,0 +1,52 @@
"""Contains CLI commands exposed to the user."""
from pathlib import Path
import click
from cctk.constants import TOOLKIT_DESCRIPTION
from cctk.rt import CONSOLE
from cctk.types import AppConfig
# define the "root" command group which contains all commands and subgroups
@click.group(
# display application description
help = TOOLKIT_DESCRIPTION,
# global context settings for Click
context_settings = dict(
# allow using -h in addition to --help
help_option_names = ["-h", "--help"],
),
)
@click.option("-R", "--repo", help="Specify the location of the challenge repository (instead of using the current working directory).")
@click.option("-v", "--verbose", is_flag=True, help="Show more verbose information.")
@click.pass_context
def root(ctx: click.Context, repo: str | None, verbose: bool):
# ensure ctx.obj exists
ctx.ensure_object(AppConfig)
assert isinstance(ctx.obj, AppConfig)
# store verbose setting
ctx.obj.verbose = verbose
# determine repo path
if repo is None:
# default to cwd
ctx.obj.repo_path = Path(".")
else:
ctx.obj.repo_path = Path(repo)
# log location of challenge repo
CONSOLE.print(f"Using challenge repo at {str(ctx.obj.repo_path.resolve(strict=False))}")
# define submodules
__all__ = [
"validate",
"version",
]
# import submodules
from . import *

98
cctk/commands/shared.py Normal file
View file

@ -0,0 +1,98 @@
"""Shared code for parsing / interpreting / validating."""
from pathlib import Path
from rich.panel import Panel
from rich.rule import Rule
from rich.table import Table
from rich.text import Text
import rich.box
from cctk.sources.challenge import Challenge
from cctk.sources.repository import ChallengeRepo
from cctk.rt import CONSOLE
from cctk.validation import Severity, ValidationBook, ValidationError
class DeploySource:
"""Represents the local sources used for a deployment."""
def __init__(self, book: ValidationBook, repo_path: Path, challenge_ids: list[str] | None = None, verbose: bool = False):
"""Initialize a new DeploySource instance.
Performs validation on loaded data.
Args:
repo_path: Local path to the challenge repository.
challenge_ids (optional): A tuple of challenge IDs to deploy. If None, all challenges found in the repository will be selected for deployment.
"""
# initialize object attributes
self._book = book
self._repo_path = repo_path
self._challenge_ids = challenge_ids
# holds the IDs of challenges we failed to load
self._failed_challenges = set()
# record whether we were given a list of challenges or not
self._challenges_specified = challenge_ids is not None
# load + validate challenge repo
with CONSOLE.status("Validating challenge repository"):
self.repo = ChallengeRepo(book, repo_path)
if verbose:
CONSOLE.print("Done validating challenge repository", style="dim")
# find challenges if required
if not self._challenges_specified:
challenge_ids = list(self.repo.find_challenges())
CONSOLE.print(f"Found {len(challenge_ids)} challenges in the repository")
else:
assert challenge_ids is not None
CONSOLE.print(f"Validating {len(challenge_ids)} challenge{'s' if len(challenge_ids) > 1 else ''}")
if verbose:
CONSOLE.print(f"Challenge IDs: {challenge_ids}", style="dim")
with CONSOLE.status("Validating challenges") as status:
# create map to store challenges
self.challenges: dict[str, Challenge] = dict()
for challenge_id in challenge_ids:
try:
self.challenges[challenge_id] = Challenge(self.repo, book, repo_path / challenge_id, challenge_id)
except ValidationError:
# we handle these validation errors later, when deployment checks whether all challenges loaded successfully
self._failed_challenges.add(challenge_id)
def rich_repo_summary(self) -> Table:
"""Build and return a rich-text summary of the loaded challenge repository."""
raise NotImplementedError
def rich_challenge_summary(self) -> Table:
"""Build and return a rich-text summary of all loaded challenges."""
loaded_challenges = Table(*["ID", "Difficulty", "Category", "Name", "Tags"], title="Loaded Challenges", box=rich.box.MINIMAL)
for challenge in self.challenges.values():
difficulty = challenge.config.difficulty
highest_issue_severity = Severity.NOTICE
for issue in self._book.get_issues(challenge.challenge_id):
if highest_issue_severity < issue.severity:
highest_issue_severity = issue.severity
if highest_issue_severity > Severity.NOTICE:
severity_style = f"validation.issue.{highest_issue_severity.as_string()}"
else:
severity_style = "green3"
loaded_challenges.add_row(
Text(challenge.challenge_id, style=severity_style),
Text(difficulty, style=f"challenge.difficulty.{difficulty}"),
challenge.config.category,
challenge.config.name,
str(challenge.config.tags),
#style = "green3",
)
return loaded_challenges

51
cctk/commands/validate.py Normal file
View file

@ -0,0 +1,51 @@
import enum
import click
from rich.console import Group
from rich.panel import Panel
from cctk.commands import root
from cctk.commands.shared import DeploySource
from cctk.rt import CONSOLE
from cctk.types import AppConfig
from cctk.validation import (FatalValidationError, ValidationBook,
ValidationError)
class ValidationMode(enum.Enum):
REPOSITORY = "repo"
CHALLENGES = "challenges"
@root.command()
@click.argument("challenges", nargs=-1)
@click.pass_context
def validate(ctx: click.Context, challenges: tuple[str]):
"""Validate challenge definitions.
Can validate either specific challenges, or the entire repository.
CHALLENGES are the IDs of the challenges to validate.
If none are specified, validation is performed on all challenges in the repository.
"""
app_ctx: AppConfig = ctx.obj
assert app_ctx.repo_path is not None
# initialize validation book
validation_book = ValidationBook()
# load repo and challenge data
try:
deploy_source = DeploySource(validation_book, app_ctx.repo_path, list(challenges) if len(challenges) > 0 else None, ctx.obj.verbose)
except (FatalValidationError, ValidationError):
raise SystemExit(1)
# print summary
CONSOLE.print(
"",
Panel(Group(
deploy_source.rich_challenge_summary(),
validation_book.rich_issue_summary(),
), title="Summary", expand=False, border_style="cyan"),
)

10
cctk/commands/version.py Normal file
View file

@ -0,0 +1,10 @@
import click
from cctk.constants import TOOLKIT_VERSION
from cctk.commands import root
@root.command()
def version():
"""Display the installed toolkit version."""
click.echo(f"CTF Challenge ToolKit {TOOLKIT_VERSION}")

7
cctk/constants.py Normal file
View file

@ -0,0 +1,7 @@
# define global metadata
TOOLKIT_DESCRIPTION = "CTF Challenge ToolKit - A tool to automate CTF challenge management."
TOOLKIT_VERSION = "0.2.0"
# define constant config file names
REPO_CONFIG_FILENAME = "challenge-repository.toml"
CHALLENGE_CONFIG_FILENAME = "challenge.toml"

36
cctk/rt.py Normal file
View file

@ -0,0 +1,36 @@
"""Rich text configuration."""
import click
import rich.console
import rich.theme
import rich.traceback
# theme with custom styles
THEME = rich.theme.Theme({
"validation.issue.fatal": "bold red1 on bright_white",
#"validation.issue.fatal": "bold red1",
"validation.issue.error": "bold red1",
"validation.issue.warning": "dark_orange",
"validation.issue.notice": "cyan",
"validation.target": "spring_green3",
"validation.location": "deep_pink3 underline",
"challenge.difficulty.undefined": "grey50",
"challenge.difficulty.easy": "spring_green3",
"challenge.difficulty.medium": "yellow3",
"challenge.difficulty.hard": "red1",
}, inherit=True)
# shared global console
CONSOLE = rich.console.Console(
theme = THEME,
# for exporting console output
record = True,
)
# use rich for pretty exceptions
rich.traceback.install(suppress=[click])

17
cctk/schemas/__init__.py Normal file
View file

@ -0,0 +1,17 @@
"""Contains Marshmallow schemas and supporting code."""
from marshmallow import validate, ValidationError
# common validators for string IDs (lowercase alphanumeric with hyphens and underscores)
VALIDATORS_STRING_ID = [
# IDs should be at least 2 characters long (assuming a single character cannot convey enough information)
validate.Length(min=2),
validate.Regexp(r"^[a-z0-9\-\_]*$", error="Must contain only lowercase alphanumeric characters, hyphens, and underscores."),
]
def validate_unique(data: list):
"""Marshmallow validator to ensure that a list does not contain duplicate items."""
if len(set(data)) != len(data):
raise ValidationError("Items must be unique.")

30
cctk/schemas/challenge.py Normal file
View file

@ -0,0 +1,30 @@
from marshmallow import fields, Schema, validate
from . import VALIDATORS_STRING_ID
class ChallengeConfigMeta(Schema):
id = fields.String(required=True, validate=VALIDATORS_STRING_ID)
name = fields.String(required=False) # not required for development
category = fields.String(required=True, validate=VALIDATORS_STRING_ID)
difficulty = fields.String(required=True, validate=validate.OneOf(["undefined", "easy", "medium", "hard"])) # 'undefined' is only for development
description = fields.String(required=False) # not required for development
tags = fields.List(fields.String(), required=False, load_default=[])
class ChallengeConfigHint(Schema):
content = fields.String(required=True)
class ChallengeConfigScoring(Schema):
flag = fields.String(required=True)
# NOTE: subject to change (scoring might be determined by difficulty)
points = fields.Integer(strict=True)
class ChallengeConfigSchema(Schema):
"""Marshmallow schema for the challenge config file."""
meta = fields.Nested(ChallengeConfigMeta, required=True)
scoring = fields.Nested(ChallengeConfigScoring, required=True)
hints = fields.List(fields.Nested(ChallengeConfigHint), required=False)

View file

@ -0,0 +1,66 @@
"""Utilities for formatting Marshmallow validation exceptions."""
from typing import Any
from io import StringIO
from rich.console import Group, RenderableType
from rich.padding import Padding
from rich.pretty import Pretty
from rich.text import Text
def collapse_to_dotted(messages: dict[str, Any]) -> dict[str, str | list[str]]:
"""Recursively collapses a nested dictionary into a single dictionary of key-value pairs (adding dots as separators)."""
def _collapse_layer(dict_in: dict[str, Any], dict_out: dict[str, Any], prefix: str):
for key, val in dict_in.items():
if isinstance(val, dict):
_collapse_layer(val, dict_out, f"{prefix}{key}.")
else:
dict_out[prefix + str(key)] = val
out: dict[str, Any] = dict()
_collapse_layer(messages, out, "")
return out
def format_validation_exception_old(messages: list[str] | dict[str, str | list[str]]) -> RenderableType:
"""Format Marshmallow validation error messages into a rich console renderable."""
sio = StringIO()
if isinstance(messages, dict):
for err_key, err_msg in messages.items():
if isinstance(err_msg, list):
sio.write(f"{err_key}: {err_msg}\n")
else:
sio.write(f"{err_key}: {err_msg}\n")
else:
for err_msg in messages:
sio.write(f"{err_msg}\n")
# return built string without the trailing newline
return sio.getvalue()[:-1]
def format_validation_exception(messages: list[str] | dict[str, str | list[str]], title: str) -> RenderableType:
"""Format Marshmallow validation error messages into a rich console renderable."""
STYLE_ERR_KEY = "cyan3"
STYLE_ERR_MSG = "yellow3"
items: list[RenderableType] = list()
if isinstance(messages, dict):
for err_key, err_msg in sorted(collapse_to_dotted(messages).items()):
t = Text.assemble(
(err_key, STYLE_ERR_KEY),
": ",
Text(str(err_msg)),
)
t.highlight_regex("'.+?'", STYLE_ERR_MSG)
items.append(t)
else:
for err_msg in messages:
items.append(Pretty(err_msg))
return Group(title, Padding(Group(*items), (0,0,0,2)))

View file

@ -0,0 +1,13 @@
from marshmallow import fields, Schema
from . import validate_unique, VALIDATORS_STRING_ID
class ChallengeRepoConfigSchema(Schema):
"""Marshmallow schema for the challenge repo config file."""
categories = fields.List(
fields.String(validate=VALIDATORS_STRING_ID),
required = True,
validate = validate_unique,
)

164
cctk/sources/challenge.py Normal file
View file

@ -0,0 +1,164 @@
from __future__ import annotations
from typing import Literal
import decimal
import enum
from pathlib import Path
import attrs
import marshmallow
from cctk import tomllib
from cctk.constants import CHALLENGE_CONFIG_FILENAME
from cctk.sources.repository import ChallengeRepo
from cctk.schemas.challenge import ChallengeConfigSchema
from cctk.schemas.formatting import format_validation_exception
from cctk.validation import (Severity, Source, ValidationBook,
ValidationError)
class ChallengeDifficulty(enum.Enum):
UNDEFINED = "undefined"
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
ChallengeDifficultyStr = Literal["undefined", "easy", "medium", "hard"]
@attrs.define(frozen=True)
class ChallengeConfig:
id: str
name: str
category: str
difficulty: ChallengeDifficultyStr
description: str | None
tags: list[str]
flag: str
points: int
hints: list[str]
@classmethod
def from_file(cls, repo: ChallengeRepo, book: ValidationBook, path: Path, challenge_id: str) -> ChallengeConfig:
pen = book.bind(challenge_id, Source(path))
validation_error = False
# attempt to load the challenge config file
try:
with path.open(mode="rb") as fp:
raw_data = tomllib.load(fp, parse_float = decimal.Decimal)
except tomllib.TOMLDecodeError as exc:
pen.issue(Severity.ERROR, "challenge-config-invalid-toml", f"Failed to parse {CHALLENGE_CONFIG_FILENAME}: {exc}")
raise ValidationError
# validate with Marshmallow schema
try:
cleaned_data = ChallengeConfigSchema().load(raw_data)
except marshmallow.ValidationError as exc:
pen.issue(Severity.ERROR, "challenge-config-schema-failure",
format_validation_exception(exc.messages, "Challenge config file failed schema validation"))
raise ValidationError
# partially destructure
clean_meta = cleaned_data["meta"]
clean_scoring = cleaned_data["scoring"]
clean_hints = cleaned_data.get("hints", [])
del cleaned_data
# rebuild dictionary to match constructor parameters
final_data = {
"id": clean_meta["id"],
"category": clean_meta["category"],
"difficulty": clean_meta["difficulty"],
"tags": clean_meta["tags"],
"flag": clean_scoring["flag"],
}
if "name" in clean_meta:
final_data["name"] = clean_meta["name"]
else:
pen.warn("challenge-config-missing-name", "Challenge config does not specify a name; using challenge ID")
final_data["name"] = clean_meta["id"]
if "description" in clean_meta:
final_data["description"] = clean_meta["description"]
else:
pen.warn("challenge-config-missing-description", "Challenge config does not specify a description")
final_data["description"] = None
if "points" in clean_scoring:
final_data["points"] = clean_scoring["points"]
else:
pen.warn("challenge-config-missing-points", "Challenge config does not specify a point value; defaulting to 0")
final_data["points"] = 0
# collapse list of hint structures
final_data["hints"] = [hint["content"] for hint in clean_hints]
# error on challenge ID mismatch
if final_data["id"] != challenge_id:
pen.issue(Severity.ERROR, "challenge-config-id-mismatch", f"Challenge config ID does not match directory name ({final_data['id']!r} != {challenge_id!r})")
validation_error = True
# error on undefined category
if final_data["category"] not in repo.categories:
pen.issue(Severity.ERROR, "challenge-", f"Challenge category is invalid ({final_data['category']!r} must match one of the repository-defined categories)")
validation_error = True
# warn on undefined difficulty
if final_data["difficulty"] == "undefined":
pen.warn("challenge-config-difficulty-undefined", "Challenge difficulty is set to 'undefined'")
# TODO: (more) custom validation steps (for warnings and non-strict-schema issues)
if validation_error:
raise ValidationError
return cls(**final_data)
class Challenge:
"""Interface to a challenge directory."""
def __init__(self, repo: ChallengeRepo, book: ValidationBook, path: Path, challenge_id: str):
"""Initialize a new Challenge interface.
Validates the challenge directories and configuration files.
Args:
book: The ValidationBook to report validation issues to.
path: The path to the challenge directory.
challenge_id: The ID of this challenge.
"""
# store object attributes
self.path = path
self.config_path = path / CHALLENGE_CONFIG_FILENAME
self.challenge_id = challenge_id
# sanity-check challenge directory
if not self.path.exists():
book.issue(Severity.FATAL, challenge_id, Source(self.path), "challenge-not-found", "Challenge directory does not exist")
elif not self.path.is_dir():
book.issue(Severity.FATAL, challenge_id, Source(self.path), "challenge-not-directory", "Challenge directory exists but is not a directory")
# sanity-check challenge config file
if not self.config_path.exists():
book.issue(Severity.FATAL, challenge_id, Source(self.config_path), "challenge-config-not-found", f"Challenge directory does not contain a {CHALLENGE_CONFIG_FILENAME} file")
elif not self.config_path.is_file():
book.issue(Severity.FATAL, challenge_id, Source(self.config_path), "challenge-config-not-file", "Challenge config exists but is not a file")
# load the config file (with validation)
self.config = ChallengeConfig.from_file(repo, book, self.config_path, challenge_id)
def __repr__(self) -> str:
return f"<Challenge id={self.challenge_id!r} config={self.config!r}>"

101
cctk/sources/repository.py Normal file
View file

@ -0,0 +1,101 @@
"""Interfaces for interacting with challenge repositories."""
from __future__ import annotations
from typing import Iterator
import decimal
from pathlib import Path
import attrs
import marshmallow
from cctk import tomllib
from cctk.constants import CHALLENGE_CONFIG_FILENAME, REPO_CONFIG_FILENAME
from cctk.schemas.formatting import format_validation_exception
from cctk.schemas.repository import ChallengeRepoConfigSchema
from cctk.validation import (Severity, Source, ValidationBook,
ValidationError)
@attrs.define(frozen=True)
class ChallengeRepoConfig:
categories: list[str]
@classmethod
def from_file(cls, book: ValidationBook, path: Path) -> ChallengeRepoConfig:
pen = book.bind(None, Source(path))
# attempt to load the repo config file
try:
with path.open(mode="rb") as fp:
raw_data = tomllib.load(fp, parse_float = decimal.Decimal)
except tomllib.TOMLDecodeError as exc:
pen.issue(Severity.ERROR, "repo-config-invalid-toml", f"Failed to parse {REPO_CONFIG_FILENAME}: {exc}")
raise ValidationError
# validate with Marshmallow schema
try:
cleaned_data = ChallengeRepoConfigSchema().load(raw_data)
except marshmallow.ValidationError as exc:
pen.issue(Severity.ERROR, "repository-config-schema-failure",
format_validation_exception(exc.messages, "Repository config file failed schema validation"))
raise ValidationError
# TODO: custom validation steps (for warnings and non-strict-schema issues)
return cls(**cleaned_data)
class ChallengeRepo:
"""Interface to a challenge repository."""
def __init__(self, book: ValidationBook, path: Path) -> None:
"""Initialize a new ChallengeRepo interface.
Validates the challenge repository and its configuration.
Args:
book: The ValidationBook to report validation issues to.
path: The path to the challenge repository.
"""
# store object attributes
self.path = path
self.config_path = path / REPO_CONFIG_FILENAME
# NOTE: technically some of these issues don't have to be fatal, but it's not worth the special-casing to handle
# sanity-check repo directory
if not self.path.exists():
book.issue(Severity.FATAL, None, Source(self.path), "repo-not-found", "Challenge repository directory does not exist")
elif not self.path.is_dir():
book.issue(Severity.FATAL, None, Source(self.path), "repo-not-directory", "Challenge repository directory exists but is not a directory")
# sanity-check repo config file
if not self.config_path.exists():
book.issue(Severity.FATAL, None, Source(self.config_path), "repo-config-not-found", f"Challenge repository does not contain a {REPO_CONFIG_FILENAME} file")
elif not self.config_path.is_file():
book.issue(Severity.FATAL, None, Source(self.config_path), "repo-config-not-file", "Challenge repository config exists but is not a file")
# load the repo config file (with validation)
self.config = ChallengeRepoConfig.from_file(book, self.config_path)
@property
def categories(self) -> list[str]:
"""Return a list of category IDs, as defined in the repo config file."""
return self.config.categories
def find_challenges(self) -> Iterator[str]:
"""Collect and return a list of all challenges within this repository."""
# iterate over all items within the directory
for subpath in self.path.iterdir():
# filter out non-directories
if subpath.is_dir():
# ignore directories without a challenge config
if (subpath / CHALLENGE_CONFIG_FILENAME).exists():
yield subpath.name

16
cctk/types.py Normal file
View file

@ -0,0 +1,16 @@
"""Data-centered types."""
from pathlib import Path
import attrs
@attrs.define
class AppConfig:
"""Holds application configuration (as set from Click)."""
# Whether to output verbose messages
verbose: bool = False
# Path to the challenge repository to use.
repo_path: Path | None = None

34
cctk/util.py Normal file
View file

@ -0,0 +1,34 @@
"""Utility functions."""
def _fnv_1a_64(text: bytes) -> int:
"""Computes the 64-bit FNV-1a hash of an input string.
See http://www.isthe.com/chongo/tech/comp/fnv/#FNV-1a
"""
h = 14695981039346656037
for byte in text:
h ^= byte
h *= 1099511628211
return h
def challenge_id_hash(challenge_id: str) -> int:
"""Implements a variation of FNV-1a to derive a 28-bit integer hash from a string ID.
Hash values will be positive integers in the range `[2^28, 2*2^28)`
28-bits was chosen to easily fit within the unsigned portion of a signed 32-bit integer,
without sacrificing collision probability. Given 500 challenges, the chance of a collision is below 1/2000.
"""
# calculate 64-bit FNV-1a hash
fnv64hash = _fnv_1a_64(challenge_id.encode())
# fold into 28-bit hash (http://www.isthe.com/chongo/tech/comp/fnv/#xor-fold)
hash28 = ((fnv64hash >> 28) ^ fnv64hash) & (2**28 - 1)
# add 2^28 for a uniform base-10 length
return hash28 + 2**28

208
cctk/validation.py Normal file
View file

@ -0,0 +1,208 @@
"""Validation of local sources"""
from __future__ import annotations
import enum
from pathlib import Path
import attrs
from rich.columns import Columns
from rich.console import RenderableType
from rich.table import Table
from rich.text import Text
import rich.box
from cctk.rt import CONSOLE
class ValidationError(Exception):
"""Exception raised when a validation error occurs (should be caught by validation caller)."""
class FatalValidationError(ValidationError):
"""Exception raised when a fatal validation error occurs."""
class Severity(enum.IntEnum):
"""Represents the severity of a validation issue."""
FATAL = 50
ERROR = 40
WARNING = 30
NOTICE = 25
def as_string(self) -> str:
return self.name.lower()
@attrs.define
class Source:
"""Metadata regarding the source of an error."""
path: Path
line: int | None = None
col: int | None = None
def __str__(self) -> str:
# TODO: rich formatting? (unless line/col is never used)
if self.line is None:
return str(self.path)
elif self.col is None:
return f"{str(self.path)}:{self.line}"
else:
return f"{str(self.path)}:{self.line}:{self.col}"
@attrs.define(frozen=True)
class Issue:
severity: Severity
challenge_id: str | None
source: Source
code: str
message: RenderableType
class ValidationBook:
"""Handles recording and reporting of validation issues."""
TEXT_ISSUE_PREFIXES = {
Severity.FATAL: Text("Fatal validation error", "validation.issue.fatal"),
Severity.ERROR: Text("Validation error", "validation.issue.error"),
Severity.WARNING: Text("Validation warning", "validation.issue.warning"),
Severity.NOTICE: Text("Validation notice", "validation.issue.notice"),
}
TEXT_TARGET_REPO = Text("challenge repository", "validation.target")
def __init__(self):
self._issues: dict[str | None, list[Issue]] = dict()
def _record_issue(self, issue: Issue):
try:
self._issues[issue.challenge_id].append(issue)
except KeyError:
self._issues[issue.challenge_id] = [issue]
def _display_issue(self, issue: Issue):
"""Display the validation issue to the user."""
issue_prefix = self.TEXT_ISSUE_PREFIXES[issue.severity]
if issue.challenge_id is None:
validation_target = self.TEXT_TARGET_REPO
else:
validation_target = Text.assemble("challenge ", (f"{issue.challenge_id}", "validation.target"))
issue_location = Text.assemble("[location: ", Text(f"{issue.source}", "validation.location"), "]")
issue_location.stylize("dim")
CONSOLE.print(
# display issue header
Text.assemble(issue_prefix, " from ", validation_target, " ", issue_location),
# display specific message (indented with "arrow")
Columns([" └─ ", issue.message], padding=0),
)
def bind(self, challenge_id: str | None, source: Source) -> ValidationBoundPen:
return ValidationBoundPen(self, challenge_id, source)
def issue(self, severity: Severity, challenge_id: str | None, source: Source, code: str, message: RenderableType):
"""Raise a validation issue.
Args:
severity: The severity of the validation issue.
challenge_id: The challenge ID of the challenge (or None if this issue applies to the challenge repo itself).
location: A string detailing the source of the issue.
code: A short kebab-case ID for this specific validation issue.
message: A human-friendly description of the validation issue.
"""
issue = Issue(severity, challenge_id, source, code, message)
# record the issue
self._record_issue(issue)
# display to user
self._display_issue(issue)
# raise exception on fatal issues
if severity == Severity.FATAL:
raise FatalValidationError
def get_issues(self, challenge_id: str | None) -> list[Issue]:
return list(self._issues.get(challenge_id, []))
def rich_issue_summary(self) -> Table | Text:
"""Generate a rich text summary of all recorded issues (or an "all clear" if no issues were found)."""
if len(self._issues) == 0:
return Text("No validation issues have occurred.", style="green3")
ISSUE_COUNT_SEPARATOR = Text(", ")
# sort challenges according to highest severity issues
challenge_issues: dict[Severity, dict[str, list[Issue]]] = { s : dict() for s in Severity }
for challenge_id, issues in self._issues.items():
if challenge_id is None:
continue
max_severity = Severity.NOTICE
for issue in issues:
if issue.severity > max_severity:
max_severity = issue.severity
challenge_issues[max_severity][challenge_id] = issues
#table = Table(*["Challenge ID", "Issues"], title="Validation Issues", title_style="italic orange3")
table = Table(title="Validation Issues", title_style="italic orange3", box=rich.box.MINIMAL)
table.add_column("Challenge ID", ratio=2)
table.add_column("Issues", ratio=5)
for max_severity, severity_challenges in sorted(challenge_issues.items(), reverse=True):
for challenge_id, issues in sorted(severity_challenges.items()):
issue_count_strings = []
for severity in Severity:
issue_count = len([issue for issue in issues if issue.severity == severity])
if issue_count > 0:
issue_count_strings.append(Text(
f"{issue_count} {severity.name.lower()}",
style=f"validation.issue.{severity.name.lower()}",
))
issue_count_strings.append(ISSUE_COUNT_SEPARATOR)
table.add_row(
Text(challenge_id, style=f"validation.issue.{max_severity.name.lower()}"),
Text.assemble(
# total issue count
Text(f"{len(issues)} issue{'s' if len(issues) > 1 else ''}"),
" (",
# issue severity breakdown
*issue_count_strings[:-1],
")",
),
)
return table
class ValidationBoundPen:
"""Like a bound logger, but for validation issues."""
def __init__(self, book: ValidationBook, challenge_id: str | None, source: Source):
self._book = book
self.challenge_id = challenge_id
self.source = source
def issue(self, severity: Severity, code: str, message: RenderableType):
"""Raise a validation issue with a bound context.
Args:
severity: The severity of the validation issue.
code: A short kebab-case ID for this specific validation issue.
message: A human-friendly description of the validation issue.
"""
return self._book.issue(severity, self.challenge_id, self.source, code, message)
def warn(self, code: str, message: RenderableType):
"""Raise a validation warning with a bound context.
Args:
code: A short kebab-case ID for this specific validation issue.
message: A human-friendly description of the validation issue.
"""
return self._book.issue(Severity.WARNING, self.challenge_id, self.source, code, message)

289
poetry.lock generated Normal file
View file

@ -0,0 +1,289 @@
[[package]]
name = "attrs"
version = "22.1.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=3.5"
[package.extras]
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.5"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "commonmark"
version = "0.9.1"
description = "Python parser for the CommonMark Markdown spec"
category = "main"
optional = false
python-versions = "*"
[package.extras]
test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
[[package]]
name = "isort"
version = "5.10.1"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
python-versions = ">=3.6.1,<4.0"
[package.extras]
colors = ["colorama (>=0.4.3,<0.5.0)"]
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
plugins = ["setuptools"]
requirements_deprecated_finder = ["pip-api", "pipreqs"]
[[package]]
name = "marshmallow"
version = "3.17.1"
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
packaging = ">=17.0"
[package.extras]
dev = ["flake8 (==5.0.4)", "flake8-bugbear (==22.8.22)", "mypy (==0.971)", "pre-commit (>=2.4,<3.0)", "pytest", "pytz", "simplejson", "tox"]
docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.9)", "sphinx (==5.1.1)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"]
lint = ["flake8 (==5.0.4)", "flake8-bugbear (==22.8.22)", "mypy (==0.971)", "pre-commit (>=2.4,<3.0)"]
tests = ["pytest", "pytz", "simplejson"]
[[package]]
name = "mypy"
version = "0.971"
description = "Optional static typing for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mypy-extensions = ">=0.4.3"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = ">=3.10"
[package.extras]
dmypy = ["psutil (>=4.0)"]
python2 = ["typed-ast (>=1.4.0,<2)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "Pygments"
version = "2.13.0"
description = "Pygments is a syntax highlighting package written in Python."
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
plugins = ["importlib-metadata"]
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main"
optional = false
python-versions = ">=3.6.8"
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pytoolconfig"
version = "1.2.2"
description = "Python tool configuration"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
packaging = ">=21.3"
tomli = {version = ">=2.0", markers = "python_version < \"3.11\""}
[package.extras]
doc = ["sphinx (>=4.5.0)", "tabulate (>=0.8.9)"]
gen_docs = ["pytoolconfig[doc]", "sphinx (>=4.5.0)", "sphinx-autodoc-typehints (>=1.18.1)", "sphinx-rtd-theme (>=1.0.0)"]
global = ["appdirs (>=1.4.4)"]
validation = ["pydantic (>=1.7.4)"]
[[package]]
name = "rich"
version = "12.5.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
category = "main"
optional = false
python-versions = ">=3.6.3,<4.0.0"
[package.dependencies]
commonmark = ">=0.9.0,<0.10.0"
pygments = ">=2.6.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
[[package]]
name = "rope"
version = "1.3.0"
description = "a python refactoring library..."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
pytoolconfig = ">=1.1.2"
[package.extras]
dev = ["build (>=0.7.0)", "pytest (>=7.0.1)", "pytest-timeout (>=2.1.0)"]
doc = ["pytoolconfig[doc]", "sphinx (>=4.5.0)", "sphinx-autodoc-typehints (>=1.18.1)", "sphinx-rtd-theme (>=1.0.0)"]
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "typing-extensions"
version = "4.3.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "dev"
optional = false
python-versions = ">=3.7"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "9904b6af2c4c21959cb0c8a39087b3b9f204726958ff6c00eb299bfd5ca404a2"
[metadata.files]
attrs = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
]
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
colorama = [
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
]
commonmark = [
{file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
{file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
]
isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
]
marshmallow = [
{file = "marshmallow-3.17.1-py3-none-any.whl", hash = "sha256:1172ce82765bf26c24a3f9299ed6dbeeca4d213f638eaa39a37772656d7ce408"},
{file = "marshmallow-3.17.1.tar.gz", hash = "sha256:48e2d88d4ab431ad5a17c25556d9da529ea6e966876f2a38d274082e270287f0"},
]
mypy = [
{file = "mypy-0.971-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"},
{file = "mypy-0.971-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5"},
{file = "mypy-0.971-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3"},
{file = "mypy-0.971-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655"},
{file = "mypy-0.971-cp310-cp310-win_amd64.whl", hash = "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103"},
{file = "mypy-0.971-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca"},
{file = "mypy-0.971-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417"},
{file = "mypy-0.971-cp36-cp36m-win_amd64.whl", hash = "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09"},
{file = "mypy-0.971-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8"},
{file = "mypy-0.971-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0"},
{file = "mypy-0.971-cp37-cp37m-win_amd64.whl", hash = "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2"},
{file = "mypy-0.971-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27"},
{file = "mypy-0.971-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856"},
{file = "mypy-0.971-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71"},
{file = "mypy-0.971-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27"},
{file = "mypy-0.971-cp38-cp38-win_amd64.whl", hash = "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58"},
{file = "mypy-0.971-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6"},
{file = "mypy-0.971-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe"},
{file = "mypy-0.971-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9"},
{file = "mypy-0.971-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf"},
{file = "mypy-0.971-cp39-cp39-win_amd64.whl", hash = "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0"},
{file = "mypy-0.971-py3-none-any.whl", hash = "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9"},
{file = "mypy-0.971.tar.gz", hash = "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
Pygments = [
{file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"},
{file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
]
pyparsing = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
pytoolconfig = [
{file = "pytoolconfig-1.2.2-py3-none-any.whl", hash = "sha256:825d97b052e58b609c2684b04efeb543075588d33a4916a6dc2ae39676458c7d"},
{file = "pytoolconfig-1.2.2.tar.gz", hash = "sha256:2512a1f261a40e73cef2e58e786184261b60c802ae7ed01249342b1949ec3aa2"},
]
rich = [
{file = "rich-12.5.1-py3-none-any.whl", hash = "sha256:2eb4e6894cde1e017976d2975ac210ef515d7548bc595ba20e195fb9628acdeb"},
{file = "rich-12.5.1.tar.gz", hash = "sha256:63a5c5ce3673d3d5fbbf23cd87e11ab84b6b451436f1b7f19ec54b6bc36ed7ca"},
]
rope = [
{file = "rope-1.3.0-py3-none-any.whl", hash = "sha256:f0c82bd7167c2926339c6f0d9ecf7c93d6866cbe380c78639fa1bc4ac037c097"},
{file = "rope-1.3.0.tar.gz", hash = "sha256:4e8ede637d8f43eb83847ef9ea7edbf4ceb9d641deea592ed38a8875cde64265"},
]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
typing-extensions = [
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
]

38
pyproject.toml Normal file
View file

@ -0,0 +1,38 @@
[tool.poetry]
name = "cctk"
version = "0.2.0"
description = "CTF Challenge ToolKit - for automated challenge management."
authors = ["Thomas Bork <sudoBash418@gmail.com>"]
[tool.poetry.scripts]
cctk = "cctk.__main__:entrypoint"
[tool.poetry.dependencies]
python = "^3.10"
click = "^8.1.3"
rich = "^12.5.1"
tomli = { version = "^2.0.1", python = "<3.11" } # Python >=3.11 distributes tomllib in the stdlib
attrs = "^22.1.0"
marshmallow = "^3.17.1"
[tool.poetry.dev-dependencies]
mypy = "^0.971"
rope = "^1.3.0"
isort = "^5.10.1"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.isort]
profile = "google"
src_paths = ["cctk"]
known_typing = ["typing", "types", "typing_extensions"]
sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
no_lines_before = ["TYPING"]
force_single_line = false
lines_after_imports = 2