Initial commit

This commit is contained in:
Maurizio Porrato 2022-11-13 10:47:02 +00:00
commit 60fc3dc835
8 changed files with 220 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
*[~%]
*.py[cod]
*.egg-info/
__pycache__/
.idea/
build/

0
ctftool/__init__.py Normal file
View File

86
ctftool/api.py Normal file
View File

@ -0,0 +1,86 @@
import os
from configparser import ConfigParser
from typing import Any, Optional
from urllib.parse import urljoin, urlparse
from requests import Session, get
class CTFdClient:
"""Client implementation of the CTFd API"""
def __init__(self, baseurl: str, token: str, verify_ssl: bool = True):
self._base_url = baseurl.rstrip("/")
self._session = Session()
self._session.headers["Authorization"] = f"Token {token}"
self._session.headers["Content-Type"] = "application/json"
self._verify_ssl = verify_ssl
@classmethod
def from_config(
cls,
config_file: str = "~/.config/ctftool/ctftool.conf",
section: Optional[str] = None,
) -> "CTFdClient":
cfg = ConfigParser()
cfg.read(os.path.expanduser(config_file))
if section is None:
sections = [x for x in cfg.sections() if x != "DEFAULT"]
section = sections[0]
section = cfg[section]
return cls(section["url"], section["token"], section.getboolean("verify", True))
def download_file(self, url: str, out: str = None) -> str:
if out is None:
out = os.curdir
out = os.path.realpath(out)
full_url = urljoin(self._base_url, url)
file_name = urlparse(full_url).path.rsplit("/", 1)[-1]
with get(full_url, stream=True) as r:
r.raise_for_status()
disposition = r.headers.get("Content-Disposition").strip()
if disposition:
if disposition.lower().startswith("attachment;"):
_, rest = disposition.split(";", 1)
rest = rest.strip()
if rest.lower().startswith("filename="):
_, file_name = rest.split("=", 1)
file_name = file_name.strip()
file_path = os.path.join(out, file_name)
with open(file_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return file_path
def api_url(self, url: str) -> str:
return f"{self._base_url}/api/v1{url}"
def get(self, url: str, **kwargs) -> dict[str, Any]:
response = self._session.get(
self.api_url(url), verify=self._verify_ssl, **kwargs
)
response.raise_for_status()
return response.json()
def post(self, url: str, data=None, json=None, **kwargs) -> dict[str, Any]:
response = self._session.post(
self.api_url(url), data, json, verify=self._verify_ssl, **kwargs
)
response.raise_for_status()
return response.json()
def get_challenge_list(self) -> dict[str, Any]:
return self.get("/challenges")
def get_challenge(self, challenge_id: int) -> dict[str, Any]:
return self.get(f"/challenges/{challenge_id}")
def post_challenge_attempt(
self, challenge_id: int, submission: str
) -> dict[str, Any]:
return self.post(
"/challenges/attempt",
json=dict(challenge_id=challenge_id, submission=submission),
)

85
ctftool/cli.py Normal file
View File

@ -0,0 +1,85 @@
import os
from typing import Any, Optional
from urllib.parse import urlparse
from jinja2 import Environment, PackageLoader, select_autoescape
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
from ctftool.api import CTFdClient
def _make_table(rows: list[dict[str, Any]], fields: list[str]) -> Table:
table = Table()
types = {}
for row in rows:
for field, value in [(x, row.get(x)) for x in fields]:
ft = type(value)
if field in types:
if value is not None and types[field] != ft:
types[field] = "mixed"
else:
types[field] = ft
for field, field_type in [(x, types[x]) for x in fields]:
if field_type in (int, float):
table.add_column(field, justify="right")
else:
table.add_column(field, justify="left")
for row in rows:
table.add_row(*(str(row.get(x)) for x in fields))
return table
class CLI:
"""CLI tool to interact with a CTFd service"""
def __init__(
self,
config_file: str = "~/.config/ctftool/ctftool.conf",
event: Optional[str] = None,
):
self.ctfd = CTFdClient.from_config(config_file, event)
self.env = Environment(
loader=PackageLoader("ctftool"), autoescape=select_autoescape()
)
self.con = Console()
def list(self):
"""List challenges"""
r = self.ctfd.get_challenge_list()["data"]
self.con.print(
_make_table(
r, ["id", "value", "category", "name", "solves", "solved_by_me", "tags"]
)
)
ls = list
def show(self, challenge_id: int, save: bool = False, render: bool = False):
"""Display a specific challenge and optionally download it"""
challenge = self.ctfd.get_challenge(challenge_id)["data"]
attached_file_urls = challenge.get("files", [])
attached_files = [urlparse(x).path.split("/")[-1] for x in attached_file_urls]
md = self.env.get_template("challenge.md").render(
challenge=challenge, attached_files=attached_files
)
if save:
out = os.path.realpath(os.curdir)
with open(os.path.join(out, "challenge.md"), "w") as fd:
fd.write(md)
for cfile in attached_file_urls:
self.ctfd.download_file(cfile, out)
if render:
self.con.print(Markdown(md))
else:
self.con.print(md)
cat = show
def submit(self, challenge_id: int, flag: str):
"""Submit the flag for a challenge"""
self.con.print(self.ctfd.post_challenge_attempt(challenge_id, flag))

View File

@ -0,0 +1,14 @@
# {{ challenge.name }}
{{ challenge.category }} - {{ challenge.value }} points
{{ challenge.description | wordwrap(78) | indent(width='> ', first=True, blank=True) }}
{%- if attached_files %}
## Attached files
{% for item in attached_files -%}
- {{ item }}
{% endfor -%}
{%- endif -%}

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
fire
Jinja2
requests
rich

7
scripts/ctf Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env python3
import fire
from ctftool.cli import CLI
if __name__ == "__main__":
fire.Fire(CLI)

18
setup.py Normal file
View File

@ -0,0 +1,18 @@
from setuptools import setup, find_packages
setup(
name="ctftool",
version="0.1.0",
description="Command line tool to interact with CTFd",
author="Maurizio Porrato",
author_email="maurizio.porrato@mailbox.org",
scripts=["scripts/ctf"],
packages=find_packages(),
package_data={"ctftool": ["templates/*"]},
install_requires=[
"fire",
"Jinja2",
"requests",
"rich",
],
)