├── .nojekyll ├── nizkctf ├── __init__.py ├── cli │ ├── __init__.py │ ├── log.py │ ├── teamsecrets.py │ ├── team.py │ ├── challenges.py │ └── scoreboard.py ├── text.py ├── repohost │ ├── __init__.py │ ├── common.py │ ├── github.py │ └── gitlab.py ├── settings.py ├── six.py ├── localsettings.py ├── acceptedsubmissions.py ├── proof.py ├── serializable.py ├── challenge.py ├── subrepo.py ├── team.py └── proposal.py ├── pip-requirements.txt ├── Makefile ├── challenges ├── index.json ├── fixed_ip.json ├── p0l1sh.json ├── matrix-gift.json ├── encrypted.json ├── hmac.json └── futuro.json ├── .gitignore ├── settings.json ├── known_hosts ├── index.html ├── README.md ├── frontend └── js │ └── functions.js ├── lambda_function.py └── ctf /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nizkctf/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nizkctf/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pip-requirements.txt: -------------------------------------------------------------------------------- 1 | requests[security] >= 2.13.0 2 | pysodium >= 0.6.9.1 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | python -m SimpleHTTPServer 8080 3 | 4 | ../lambda.zip: FORCE 5 | rm -f "$@" 6 | zip -ry "$@" * .git* 7 | 8 | FORCE: 9 | -------------------------------------------------------------------------------- /challenges/index.json: -------------------------------------------------------------------------------- 1 | [ 2 | "encrypted", 3 | "fixed_ip", 4 | "hmac", 5 | "p0l1sh", 6 | "futuro", 7 | "matrix-gift" 8 | ] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # our own stuff 2 | /submissions 3 | /local-settings.json 4 | /team-secrets.json 5 | 6 | # elm-make 7 | /elm-stuff 8 | 9 | # python 10 | *.pyc 11 | *.pyo 12 | 13 | # wingide 14 | *.wpr 15 | *.wpu 16 | -------------------------------------------------------------------------------- /nizkctf/text.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import unicodedata 6 | 7 | 8 | def width(s): 9 | asian = sum(unicodedata.east_asian_width(c) in {'W', 'F'} for c in s) 10 | return len(s) + asian 11 | -------------------------------------------------------------------------------- /challenges/fixed_ip.json: -------------------------------------------------------------------------------- 1 | {"description": "PT: Digite IP e porta para receber a FLAG! EN: Type IP and Port to receive the FLAG! http://138.197.9.129:8002/", "title": "fixed_ip", "tags": ["Web"], "points": 40, "pk": "bJn3vUZGeyVBA/L4yZiOQQcMnL0ijwAHNl6Zo76pXXg=", "salt": "eBl10Wg93p0u+CmeAIyCW3LfD6AcnqL+yfmcHzcNYrQ=", "id": "fixed_ip"} 2 | -------------------------------------------------------------------------------- /nizkctf/repohost/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | from .github import GitHub 6 | from .gitlab import GitLab 7 | 8 | from ..settings import Settings 9 | RepoHost = globals()[Settings.repository_host] 10 | -------------------------------------------------------------------------------- /nizkctf/cli/log.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | 6 | 7 | def info(s): 8 | print('\033[93m[*]\033[00m %s' % s) 9 | 10 | 11 | def success(s): 12 | print('\033[92m[+]\033[00m %s' % s) 13 | 14 | 15 | def fail(s): 16 | print('\033[91m[!]\033[00m %s' % s) 17 | -------------------------------------------------------------------------------- /challenges/p0l1sh.json: -------------------------------------------------------------------------------- 1 | {"description": "PT: c0n3c73 n4 p0r74 d0 pr0c3554d0r XT 3m 104.219.54.49 3 pr055164. Adicione CTF-BR{} ao submeter. EN: C0n3c7 t0 XT's CPU p0r7 1n 104.219.54.49 4nd m0v3 0n. Add CTF-BR{} on submission.", "title": "p0l1sh", "tags": ["Miscellaneous"], "points": 20, "pk": "iZDJdmuJQ6Ko5duhjMkz4ac+ZV+P87oB0z2kp+NvItM=", "salt": "IyRZI4jVh7RRokW+943L5orKR27lam0qR9O6nH5tEN0=", "id": "p0l1sh"} -------------------------------------------------------------------------------- /challenges/matrix-gift.json: -------------------------------------------------------------------------------- 1 | {"description": "PT: Na matrix testo meus limites e ela me recompensa. EN: In the matrix I test my skills and I'm always rewarded. https://ctf.tecland.com.br/homologacao/Matrix-Gift.tar.gz", "title": "Matrix Gift", "tags": ["Reversing"], "points": 50, "pk": "f9Hq+0royBzlZ+LsaIB99ha059tfLBoSDJCxPdS3tyA=", "salt": "iZnUQk5/8Xlafki96En5P2FrvmvV2L9MbZ1f5mJ+QUI=", "id": "matrix-gift"} 2 | -------------------------------------------------------------------------------- /challenges/encrypted.json: -------------------------------------------------------------------------------- 1 | {"description": "PT: Voc\u00ea consegue reverter isso? Basta reverter essa string com base no bin\u00e1rio, boa sorte. EN: Just revert this string based on the binary, good luck. Hash: https://goo.gl/USPE9t Bin: https://ctf.tecland.com.br/homologacao/encrypted.tar.gz", "title": "Encrypted", "tags": ["Reversing"], "points": 60, "pk": "mVtYzq/vX0qgvhEpFMHlizrODy/+69TtUlFEJL1VG9M=", "salt": "ag0ZFDH5eaf2suDcThqzrnk7vKVSWQoUrldqTCAostQ=", "id": "encrypted"} 2 | -------------------------------------------------------------------------------- /challenges/hmac.json: -------------------------------------------------------------------------------- 1 | {"description": "PT: Voc\u00ea conseguiria quebrar esse hash pra n\u00f3s? \u00c9 formado por letras min\u00fasculas e a key leetz0rs_can. Submeta com CTF-BR{palavra} EN: Can you break this hash for us? ([a-z], key -> leetz0rs_can). Add CTF-BR{word} on submission. Hash: 97a4242c5a9082e6c5920e0e3dc0b9b4", "title": "HMAC", "tags": ["Cryptography"], "points": 30, "pk": "A9MwsdbveEZsGCpVkP5twdcfWNAeXrFCrnbxk0GELbE=", "salt": "V0/7FGubNKULkFO4r4Q8CakGdzuCZ32Tr3UYYs6qnFU=", "id": "hmac"} -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ctf_name": "Pwn2Win Platform Test Edition", 3 | "repository_host": "GitHub", 4 | "submissions_project": "pwn2winctf/PTEsubmissions", 5 | 6 | "scrypt_ops_limit": 33554432, 7 | "scrypt_mem_limit": 1073741824, 8 | 9 | "max_size_team_name": 50, 10 | "max_size_chall_id": 15, 11 | 12 | "github_api_endpoint": "https://api.github.com/", 13 | "github_ssh_url": "git@github.com:%s.git", 14 | 15 | "gitlab_api_endpoint": "https://gitlab.com/api/v3/", 16 | "gitlab_ssh_url": "git@gitlab.com:%s.git" 17 | } 18 | -------------------------------------------------------------------------------- /challenges/futuro.json: -------------------------------------------------------------------------------- 1 | {"description": "PT: Jakko, um finland\u00eas tamb\u00e9m conhecido como \"WiZ\", escondeu um tesouro em sustenido 0x1337 no treasure.ctf-br.org. Voc\u00ea \u00e9 capaz de encontr\u00e1-lo utilizando suas net skillz? :) EN: Jakko, a finnish also known as \"WiZ\", have a hidden treasure in sustained 0x1337 at treasure.ctf-br.org. Are you capable of find it using your net skillz? :)", "title": "Futuro", "tags": ["Networking"], "points": 50, "pk": "g6Je/ZZ+wf7XwF1TOIAzbvwG7OXJbMNU6xM5KbYg8jw=", "salt": "fqHNbTGV0o8PQ25JG6neAs6yyFxHDUBc09+9gnMxztA=", "id": "futuro"} -------------------------------------------------------------------------------- /nizkctf/settings.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import json 7 | from .six import viewitems 8 | 9 | 10 | class Settings(object): 11 | pass 12 | 13 | 14 | def load(): 15 | """ Load settings from json file """ 16 | thisdir = os.path.dirname(os.path.realpath(__file__)) 17 | with open(os.path.join(thisdir, '..', 'settings.json')) as f: 18 | settings = json.load(f) 19 | assert isinstance(settings, dict) 20 | for k, v in viewitems(settings): 21 | setattr(Settings, k, v) 22 | 23 | load() 24 | -------------------------------------------------------------------------------- /nizkctf/six.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # a subset of the six module 3 | 4 | from __future__ import unicode_literals, division, print_function,\ 5 | absolute_import 6 | import operator 7 | import sys 8 | 9 | 10 | PY2 = sys.version_info[0] == 2 11 | 12 | text_type = type('') 13 | 14 | 15 | if PY2: 16 | viewitems = operator.methodcaller("viewitems") 17 | else: 18 | viewitems = operator.methodcaller("items") 19 | 20 | 21 | def to_bytes(s): 22 | if isinstance(s, text_type): 23 | return bytes(s.encode('utf-8')) 24 | return s 25 | 26 | 27 | def to_unicode(s): 28 | if isinstance(s, text_type): 29 | return s 30 | encoding = sys.getfilesystemencoding() 31 | s = s.decode(encoding) 32 | return s 33 | -------------------------------------------------------------------------------- /nizkctf/localsettings.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import json 7 | import threading 8 | 9 | 10 | thisdir = os.path.dirname(os.path.realpath(__file__)) 11 | path = os.path.join(thisdir, '..', 'local-settings.json') 12 | 13 | 14 | class DefaultLocalSettings(object): 15 | __lock__ = threading.Lock() 16 | 17 | def __init__(self): 18 | if os.path.exists(path): 19 | with open(path) as f: 20 | self.__dict__.update(json.load(f)) 21 | 22 | def __setattr__(self, k, v): 23 | self.__dict__[k] = v 24 | with self.__lock__: 25 | with open(path, 'w') as f: 26 | json.dump(self.__dict__, f) 27 | 28 | 29 | LocalSettings = DefaultLocalSettings() 30 | -------------------------------------------------------------------------------- /known_hosts: -------------------------------------------------------------------------------- 1 | github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== 2 | gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY= 3 | gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9 4 | gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf 5 | -------------------------------------------------------------------------------- /nizkctf/acceptedsubmissions.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import time 7 | from .six import text_type 8 | from .subrepo import SubRepo 9 | from .serializable import SerializableList 10 | 11 | 12 | ACCEPTED_SUBMISSIONS_FILE = 'accepted-submissions.json' 13 | TIME_FORMAT = '%Y-%m-%dT%H:%M:%S' 14 | 15 | 16 | class AcceptedSubmissions(SerializableList): 17 | def __init__(self): 18 | super(AcceptedSubmissions, self).__init__() 19 | 20 | def path(self): 21 | return SubRepo.get_path(ACCEPTED_SUBMISSIONS_FILE) 22 | 23 | def add(self, chall_id, points, team_id): 24 | if (chall_id, team_id) in ((s['chall'], s['team']) for s in self): 25 | # Challenge already submitted by team 26 | return 27 | self.append({"chall": chall_id, 28 | "points": points, 29 | "team": team_id, 30 | "time": current_time()}) 31 | self.save() 32 | 33 | 34 | def current_time(): 35 | return time.strftime(TIME_FORMAT) 36 | -------------------------------------------------------------------------------- /nizkctf/cli/teamsecrets.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import shutil 7 | import time 8 | from . import log 9 | from ..serializable import SerializableDict 10 | 11 | 12 | TEAMSECRETS_FILE = 'team-secrets.json' 13 | 14 | 15 | class DefaultTeamSecrets(SerializableDict): 16 | def path(self): 17 | thisdir = os.path.dirname(os.path.realpath(__file__)) 18 | return os.path.join(thisdir, '..', '..', TEAMSECRETS_FILE) 19 | 20 | @staticmethod 21 | def _binary_field(k): 22 | return k.endswith('_sk') 23 | 24 | 25 | TeamSecrets = DefaultTeamSecrets() 26 | 27 | 28 | def write_team_secrets(team_id, crypt_sk, sign_sk): 29 | if TeamSecrets.exists(): 30 | log.info('overriding %s' % TEAMSECRETS_FILE) 31 | 32 | backup = TeamSecrets.path() + time.strftime('.%Y-%m-%d-%H-%M-%S') 33 | shutil.copy(TeamSecrets.path(), backup) 34 | 35 | TeamSecrets['id'] = team_id 36 | TeamSecrets['crypt_sk'] = crypt_sk 37 | TeamSecrets['sign_sk'] = sign_sk 38 | TeamSecrets.save() 39 | 40 | log.success('ready, share %s with your team!' % TEAMSECRETS_FILE) 41 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 |
15 |
16 |

Challenges

17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /nizkctf/cli/team.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import hashlib 6 | import os 7 | import json 8 | import codecs 9 | import pysodium 10 | import base64 11 | from . import log 12 | from .teamsecrets import write_team_secrets 13 | from ..team import Team 14 | from ..subrepo import SubRepo 15 | from ..six import to_unicode 16 | 17 | 18 | def register(team_name): 19 | team_name = to_unicode(team_name) 20 | 21 | log.info('updating subrepo') 22 | SubRepo.pull() 23 | 24 | log.info('registering new team: %s' % team_name) 25 | team = Team(name=team_name) 26 | 27 | if team.exists(): 28 | log.fail('team is already registered') 29 | return False 30 | 31 | log.info('generating encryption keypair') 32 | crypt_pk, crypt_sk = pysodium.crypto_box_keypair() 33 | 34 | log.info('generating signature keypair') 35 | sign_pk, sign_sk = pysodium.crypto_sign_keypair() 36 | 37 | team.update({'crypt_pk': crypt_pk, 38 | 'sign_pk': sign_pk}) 39 | team.validate() 40 | team.save() 41 | 42 | SubRepo.push(commit_message='Register team %s' % team_name) 43 | log.success('team %s added successfully' % team_name) 44 | 45 | write_team_secrets(team.id, crypt_sk, sign_sk) 46 | 47 | return True 48 | -------------------------------------------------------------------------------- /nizkctf/proof.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import pysodium 7 | from base64 import b64encode, b64decode 8 | from .six import text_type 9 | from .challenge import Challenge 10 | from .cli.teamsecrets import TeamSecrets 11 | 12 | 13 | def proof_open(team, proof): 14 | if isinstance(proof, text_type): 15 | proof = proof.encode('utf-8') 16 | 17 | assert isinstance(proof, bytes) 18 | 19 | proof = b64decode(proof) 20 | 21 | claimed_chall_id = proof[2*64:].decode('utf-8') 22 | claimed_chall = Challenge(claimed_chall_id) 23 | 24 | chall_pk = claimed_chall['pk'] 25 | team_pk = team['sign_pk'] 26 | 27 | membership_proof = pysodium.crypto_sign_open(proof, chall_pk) 28 | chall_id = pysodium.crypto_sign_open(membership_proof, 29 | team_pk).decode('utf-8') 30 | 31 | if claimed_chall_id != chall_id: 32 | raise ValueError('invalid proof') 33 | 34 | return claimed_chall 35 | 36 | 37 | def proof_create(chall_id, chall_sk): 38 | chall_id = chall_id.encode('utf-8') 39 | 40 | assert isinstance(chall_id, bytes) 41 | assert isinstance(chall_sk, bytes) 42 | 43 | team_sk = TeamSecrets['sign_sk'] 44 | 45 | membership_proof = pysodium.crypto_sign(chall_id, team_sk) 46 | proof = pysodium.crypto_sign(membership_proof, chall_sk) 47 | 48 | return b64encode(proof).decode('utf-8') 49 | -------------------------------------------------------------------------------- /nizkctf/repohost/common.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import requests 7 | import urllib 8 | import codecs 9 | from ..localsettings import LocalSettings 10 | 11 | 12 | class BaseRepoHost(object): 13 | @classmethod 14 | def login(cls, username=None, password=None, token=None): 15 | if not token and (username and password): 16 | token = cls.get_token(username, password) 17 | if not token: 18 | raise ValueError("Pass either a token or an username/password") 19 | LocalSettings.token = token 20 | 21 | @classmethod 22 | def instance(cls): 23 | token = os.getenv('REPOHOST_TOKEN') or \ 24 | LocalSettings.token 25 | return cls(token) 26 | 27 | def __init__(self, token): 28 | self.token = token 29 | self.s = requests.Session() 30 | self._init_session() 31 | 32 | def _init_session(self): 33 | pass 34 | 35 | @classmethod 36 | def get_token(cls, username, password): 37 | pass 38 | 39 | @staticmethod 40 | def _raise_for_status(r): 41 | try: 42 | r.raise_for_status() 43 | except Exception as e: 44 | raise APIError(r.text, e) 45 | 46 | 47 | class APIError(Exception): 48 | pass 49 | 50 | 51 | class WebhookAuthError(Exception): 52 | pass 53 | 54 | 55 | quote_plus = urllib.quote_plus if hasattr(urllib, 'quote_plus') else \ 56 | urllib.parse.quote_plus 57 | -------------------------------------------------------------------------------- /nizkctf/serializable.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import json 7 | from base64 import b64encode, b64decode 8 | from .six import viewitems, text_type 9 | 10 | 11 | class Serializable(object): 12 | def __init__(self): 13 | self.load() 14 | 15 | def load(self): 16 | if self.exists(): 17 | self.clear() 18 | with open(self.path()) as f: 19 | self.update(json.load(f)) 20 | self._unserialize_inplace() 21 | 22 | def save(self): 23 | with open(self.path(), 'w') as f: 24 | json.dump(self._serialize(), f) 25 | 26 | def exists(self): 27 | return os.path.exists(self.path()) 28 | 29 | def _unserialize_inplace(self): 30 | pass 31 | 32 | def _serialize(self): 33 | return self 34 | 35 | 36 | class SerializableDict(Serializable, dict): 37 | @staticmethod 38 | def _binary_field(k): 39 | return False 40 | 41 | def _unserialize_inplace(self): 42 | for k, v in viewitems(self): 43 | if self._binary_field(k): 44 | assert isinstance(v, text_type) 45 | self[k] = b64decode(v) 46 | 47 | def _serialize(self): 48 | return {k: b64encode(v).decode('utf-8') if self._binary_field(k) else v 49 | for k, v in viewitems(self)} 50 | 51 | 52 | class SerializableList(Serializable, list): 53 | def clear(self): 54 | del self[:] 55 | 56 | def update(self, l): 57 | self += l 58 | -------------------------------------------------------------------------------- /nizkctf/cli/challenges.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import codecs 7 | import json 8 | import pysodium 9 | import base64 10 | import textwrap 11 | 12 | from . import log 13 | from .teamsecrets import TeamSecrets 14 | from ..team import my_team 15 | from ..proof import proof_create 16 | from ..challenge import Challenge, lookup_flag 17 | from ..subrepo import SubRepo 18 | 19 | 20 | def submit_flag(flag, chall_id=None): 21 | chall, chall_sk = lookup_flag(flag, chall_id) 22 | 23 | if chall_sk is None: 24 | return False, 'This is not the correct flag.' 25 | 26 | SubRepo.pull() 27 | 28 | submissions = my_team().submissions() 29 | if chall in submissions.challs(): 30 | return False, 'Your team already solved %s.' % chall.id 31 | 32 | proof = proof_create(chall.id, chall_sk) 33 | submissions.submit(proof) 34 | SubRepo.push(commit_message='Proof: found flag for %s' % chall.id) 35 | 36 | return True, 'Congratulations! You found the right flag for %s.' % chall.id 37 | 38 | 39 | LINE_WIDTH = 72 40 | 41 | 42 | def pprint(): 43 | print('') 44 | print('-'*LINE_WIDTH) 45 | print('') 46 | for chall_id in Challenge.index(): 47 | chall = Challenge(chall_id) 48 | print('ID: %s (%d points) [%s]' % ( 49 | chall['id'], 50 | chall['points'], 51 | ', '.join(chall['tags']))) 52 | print('') 53 | print(chall['title']) 54 | print('') 55 | print('\n'.join(textwrap.wrap(chall['description'], 56 | LINE_WIDTH))) 57 | print('') 58 | print('-'*LINE_WIDTH) 59 | print('') 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Platform Test Edition 2 | 3 | Welcome to the Pwn2Win CTF **Platform Test Edition**. 4 | 5 | ## Registration 6 | 7 | 1. Please make sure you are using an Unicode locale, e.g. 8 | ```bash 9 | export LC_ALL=en_US.UTF-8 10 | ``` 11 | 12 | 2. All team members must have a GitHub account and [configure a SSH key in their account settings](https://github.com/settings/keys). 13 | 14 | **Note**: If you prefer team members to stay anonymous, you can create a single GitHub account for the entire team and share its credentials. 15 | 16 | 3. All team members must have the git client [correctly set up](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup). If you have never used git before, run: 17 | ```bash 18 | git config --global user.name "John Doe" 19 | git config --global user.email johndoe@example.com 20 | ``` 21 | 22 | 4. All team members must clone the repository and install the dependencies: 23 | ```bash 24 | git clone git@github.com:pwn2winctf/PTE.git 25 | cd PTE 26 | sudo apt-get install libsodium18 27 | sudo -H pip install -r pip-requirements.txt 28 | ``` 29 | **Note**: If you are using Ubuntu 14.04, add [ppa:elt/libsodium](https://launchpad.net/~elt/+archive/ubuntu/libsodium) to your system to be able to install `libsodium18`. If you are using Debian, you need to get the package from [sid](https://packages.debian.org/sid/libsodium18). 30 | 31 | 5. If dependencies are installed correctly, you should now see the help menu when calling: 32 | ```bash 33 | ./ctf -h 34 | ``` 35 | 36 | 6. The **leader of the team** must execute the following command and follow the instructions to register the team: 37 | ```bash 38 | ./ctf init 39 | ``` 40 | 41 | 7. The **other members of the team** must login to GitHub without registering a new team, by running: 42 | ```bash 43 | ./ctf login 44 | ``` 45 | 46 | 8. After that, **the leader** must share the `team-secrets.json` with the members of the team. The **other members of the team** must place the `team-secrets.json` file shared by the leader in their `PTE` directory. 47 | 48 | ## Challenges 49 | 50 | Challenges are available on https://pwn2winctf.github.io. 51 | 52 | If you prefer to browse them locally, you may also run a local webserver by typing `make`, or list challenges through the command line interface: 53 | ```bash 54 | ./ctf challs 55 | ``` 56 | 57 | ## Flag submission 58 | 59 | To submit a flag: 60 | ```bash 61 | ./ctf submit --chall chall-id 'CTF-BR{flag123}' 62 | ``` 63 | 64 | You may omit `--chall chall-id` from the command, however it will be slower to run this way. In this case, we will look for the flag in every challenge released until now. 65 | 66 | ## Scoreboard 67 | 68 | Currently, the scoreboard is only available through the command line interface: 69 | ```bash 70 | ./ctf score --names --pull 71 | ``` 72 | 73 | However we plan to make it available through the web interface in a future release. 74 | -------------------------------------------------------------------------------- /nizkctf/challenge.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | from base64 import b64decode 6 | import os 7 | import re 8 | import json 9 | import pysodium 10 | from .serializable import SerializableDict, SerializableList 11 | from .settings import Settings 12 | 13 | 14 | CHALLENGES_DIR = 'challenges' 15 | INDEX_FILE = 'index.json' 16 | 17 | thisdir = os.path.dirname(os.path.realpath(__file__)) 18 | chall_dir = os.path.realpath(os.path.join(thisdir, '..', CHALLENGES_DIR)) 19 | 20 | 21 | class Challenge(SerializableDict): 22 | def __init__(self, id): 23 | self.validate_id(id) 24 | self.id = id 25 | super(Challenge, self).__init__() 26 | 27 | def __eq__(self, other): 28 | return self.id == other.id 29 | 30 | def __hash__(self): 31 | return hash(self.id) 32 | 33 | def path(self): 34 | return os.path.join(chall_dir, self.id + '.json') 35 | 36 | @staticmethod 37 | def validate_id(id): 38 | if len(id) > Settings.max_size_chall_id or \ 39 | not re.match(r'^[a-zA-Z0-9-_]+$', id): 40 | raise ValueError('invalid challenge ID') 41 | 42 | @staticmethod 43 | def _binary_field(k): 44 | return k in {'salt', 'pk'} 45 | 46 | @staticmethod 47 | def index(): 48 | return ChallengeIndex() 49 | 50 | 51 | class ChallengeIndex(SerializableList): 52 | def path(self): 53 | return os.path.join(chall_dir, INDEX_FILE) 54 | 55 | 56 | def derive_keypair(salt, flag): 57 | flag = flag.encode('utf-8') 58 | assert isinstance(salt, bytes) 59 | assert isinstance(flag, bytes) 60 | assert len(salt) == pysodium.crypto_pwhash_scryptsalsa208sha256_SALTBYTES 61 | 62 | chall_seed = pysodium.crypto_pwhash_scryptsalsa208sha256( 63 | pysodium.crypto_sign_SEEDBYTES, 64 | flag, 65 | salt, 66 | Settings.scrypt_ops_limit, 67 | Settings.scrypt_mem_limit) 68 | 69 | return pysodium.crypto_sign_seed_keypair(chall_seed) 70 | 71 | 72 | def random_salt(): 73 | return pysodium.randombytes( 74 | pysodium.crypto_pwhash_scryptsalsa208sha256_SALTBYTES) 75 | 76 | 77 | def lookup_flag(flag, chall_id=None): 78 | if chall_id: 79 | # challenge provided, only try it 80 | try_challenges = [Challenge(chall_id)] 81 | if not try_challenges[0].exists(): 82 | raise ValueError("A challenge named '%s' does not exist." % 83 | chall_id) 84 | else: 85 | # try every challenge 86 | try_challenges = map(Challenge, Challenge.index()) 87 | 88 | try_salts = set(chall['salt'] for chall in try_challenges) 89 | pk_chall = {chall['pk']: chall for chall in try_challenges} 90 | 91 | for salt in try_salts: 92 | pk, sk = derive_keypair(salt, flag) 93 | match = pk_chall.get(pk) 94 | 95 | if match: 96 | return match, sk 97 | 98 | return None, None 99 | -------------------------------------------------------------------------------- /frontend/js/functions.js: -------------------------------------------------------------------------------- 1 | (function(_) { 2 | _.nizkctf = {}; 3 | 4 | var challengesDiv = $('#challenges'); 5 | var modalsDiv = $('#modals'); 6 | 7 | var challTagsTpl = function(tags) { 8 | return tags.map(function(tag){ 9 | return '' + tag + ''; 10 | }).join(''); 11 | }; 12 | 13 | var challModalTpl = function(challenge) { 14 | return ''; 26 | } 27 | 28 | var challCardTpl = function(challenge) { 29 | 30 | return '
' 31 | + '
' 32 | + '
' 33 | + '' 34 | + challenge.title 35 | + '' + challenge.points + '' 36 | + '' 37 | + '

' + challenge.description.substr(0,100) + '...' + '

' 38 | + '

' + challTagsTpl(challenge.tags) + '

' 39 | + '
' 40 | + '
' 41 | + 'More' 42 | + '
' 43 | + '
' 44 | + '
'; 45 | }; 46 | 47 | var getChallenges = function() { 48 | var mountChallTpl = function(challenge) { 49 | challengesDiv.append(challCardTpl(challenge)); 50 | modalsDiv.append(challModalTpl(challenge)); 51 | }; 52 | 53 | var mountChallPromise = function(challUrl) { 54 | return $.getJSON('challenges/' + challUrl + '.json') 55 | .then(mountChallTpl); 56 | }; 57 | 58 | var challPromiseMap = function(challList) { 59 | return $.when.apply($, challList.map(mountChallPromise)); 60 | }; 61 | 62 | return $.getJSON('challenges/index.json') 63 | .then(challPromiseMap); 64 | }; 65 | 66 | var getSettings = function() { 67 | var handleSettings = function(settings) { 68 | $('#logo-container').text(settings.ctf_name); 69 | $('title').text(settings.ctf_name); 70 | }; 71 | 72 | $.getJSON('settings.json') 73 | .then(handleSettings); 74 | }; 75 | 76 | var renderChallenges = function() { 77 | challengesDiv.html(''); 78 | getChallenges() 79 | .then(function() { 80 | $('.modal').modal(); 81 | }); 82 | } 83 | 84 | _.nizkctf.init = function() { 85 | renderChallenges(); 86 | getSettings(); 87 | } 88 | 89 | _.nizkctf.init(); 90 | 91 | })(window); 92 | -------------------------------------------------------------------------------- /nizkctf/subrepo.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import subprocess 7 | import base64 8 | import pysodium 9 | from .settings import Settings 10 | from .localsettings import LocalSettings 11 | from .repohost import RepoHost 12 | 13 | 14 | SUBREPO_NAME = 'submissions' 15 | 16 | 17 | class SubRepo(object): 18 | @classmethod 19 | def set_clone_into(cls, clone_into): 20 | cls.clone_into = clone_into 21 | cls.path = os.path.join(clone_into, SUBREPO_NAME) 22 | 23 | @classmethod 24 | def get_path(cls, subpath=''): 25 | if os.path.exists(cls.path): 26 | return os.path.join(cls.path, subpath) 27 | raise EnvironmentError("The subrepository path ('%s') was not created " 28 | "yet. Please call 'ctf login' to get it cloned " 29 | "before performing any further actions." % 30 | cls.path) 31 | 32 | @classmethod 33 | def clone(cls, fork=True): 34 | repohost = RepoHost.instance() 35 | upstream_url = repohost.get_ssh_url(Settings.submissions_project) 36 | 37 | if fork: 38 | forked_project, origin_url = \ 39 | repohost.fork(Settings.submissions_project) 40 | LocalSettings.forked_project = forked_project 41 | else: 42 | origin_url = upstream_url 43 | 44 | cls.git(['clone', origin_url, SUBREPO_NAME], cwd=cls.clone_into) 45 | cls.git(['remote', 'add', 'upstream', upstream_url]) 46 | 47 | @classmethod 48 | def pull(cls): 49 | cls.git(['checkout', 'master']) 50 | cls.git(['pull', '--rebase', 'upstream', 'master']) 51 | 52 | @classmethod 53 | def push(cls, commit_message='commit', merge_request=True): 54 | branch = 'master' 55 | if merge_request: 56 | branch = cls.random_branch() 57 | cls.git(['checkout', '-b', branch, 'master']) 58 | 59 | cls.git(['add', '-A']) 60 | cls.git(['commit', '-m', commit_message], 61 | returncodes={0, 1}) # do not fail on 'nothing to commit' 62 | cls.git(['push', '-u', 'origin', branch]) 63 | 64 | if merge_request: 65 | repohost = RepoHost.instance() 66 | repohost.merge_request(LocalSettings.forked_project, 67 | Settings.submissions_project, 68 | source_branch=branch, 69 | title=commit_message) 70 | 71 | @staticmethod 72 | def random_branch(): 73 | return base64.b32encode(pysodium.randombytes(10))\ 74 | .decode('utf-8').lower() 75 | 76 | @classmethod 77 | def git(cls, args, **kwargs): 78 | returncodes = kwargs.pop('returncodes', {0}) 79 | if 'cwd' not in kwargs: 80 | kwargs['cwd'] = cls.get_path() 81 | 82 | p = subprocess.Popen(['git'] + args, **kwargs) 83 | 84 | r = None 85 | if 'stdout' in kwargs: 86 | r = p.stdout.read() 87 | 88 | returncode = p.wait() 89 | if returncode not in returncodes: 90 | raise GitError(returncode) 91 | 92 | return r 93 | 94 | 95 | class GitError(Exception): 96 | def __init__(self, returncode, *args): 97 | self.returncode = returncode 98 | super(GitError, self).__init__(*args) 99 | 100 | 101 | thisdir = os.path.dirname(os.path.realpath(__file__)) 102 | SubRepo.set_clone_into(os.path.realpath(os.path.join(thisdir, '..'))) 103 | -------------------------------------------------------------------------------- /lambda_function.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | from nizkctf.settings import Settings 6 | from nizkctf.subrepo import SubRepo 7 | from nizkctf.repohost import RepoHost 8 | from nizkctf.proposal import consider_proposal 9 | from nizkctf.six import to_bytes 10 | import os 11 | import json 12 | import base64 13 | import tempfile 14 | import traceback 15 | 16 | 17 | def run(merge_info): 18 | SubRepo.set_clone_into(tempfile.mkdtemp()) 19 | 20 | # Prepare git and ssh for usage inside the container 21 | setup_environment() 22 | 23 | # Merge proposal if changes are valid 24 | consider_proposal(merge_info) 25 | 26 | 27 | def handle_payload(payload, context): 28 | merge_info = RepoHost.webhook.adapt_payload(payload) 29 | 30 | if not merge_info: 31 | # Message not of our interest (e.g. merge request closed) 32 | return 33 | 34 | try: 35 | run(merge_info) 36 | except: 37 | # Do not re-raise, we do not want automatic retries 38 | traceback.print_exc() 39 | # Send tracking number to the user 40 | send_cloudwatch_info(merge_info, context) 41 | 42 | 43 | def send_cloudwatch_info(merge_info, context): 44 | proj = Settings.submissions_project 45 | mr_id = merge_info['mr_id'] 46 | 47 | comment = "Sorry. A failure has occurred when processing your proposal. " \ 48 | "Please contact support and present the following info:\n\n" \ 49 | "**Stream name**: %s\n" \ 50 | "**Request ID**: %s\n" % \ 51 | (context.log_stream_name, context.aws_request_id) 52 | 53 | repohost = RepoHost.instance() 54 | repohost.mr_comment(proj, mr_id, comment) 55 | repohost.mr_close(proj, mr_id) 56 | 57 | 58 | def handle_apigw(event, context): 59 | headers = event['params']['header'] 60 | raw_payload = event['body'] 61 | 62 | # autenticate the message 63 | secret = to_bytes(os.getenv('WEBHOOK_SECRET_TOKEN')) 64 | RepoHost.webhook.auth(secret, headers, to_bytes(raw_payload)) 65 | 66 | payload = json.loads(raw_payload) 67 | return handle_payload(payload, context) 68 | 69 | 70 | def handle_sns(event, context): 71 | raw_payload = event['Records'][0]['Sns']['Message'] 72 | payload = json.loads(raw_payload) 73 | 74 | # no way to authenticate, but also no need to 75 | # (publishing to the SNS topic should already be authenticated) 76 | 77 | return handle_payload(payload, context) 78 | 79 | 80 | def setup_environment(): 81 | root = os.getenv('LAMBDA_TASK_ROOT') 82 | bin_dir = os.path.join(root, 'bin') 83 | os.environ['PATH'] += ':' + bin_dir 84 | os.environ['GIT_EXEC_PATH'] = bin_dir 85 | 86 | ssh_dir = tempfile.mkdtemp() 87 | 88 | ssh_identity = os.path.join(ssh_dir, 'identity') 89 | with os.fdopen(os.open(ssh_identity, os.O_WRONLY | os.O_CREAT, 0o600), 90 | 'w') as f: 91 | f.write(base64.b64decode(os.getenv('SSH_IDENTITY'))) 92 | 93 | ssh_config = os.path.join(ssh_dir, 'config') 94 | with open(ssh_config, 'w') as f: 95 | f.write('CheckHostIP no\n' 96 | 'StrictHostKeyChecking yes\n' 97 | 'IdentityFile %s\n' 98 | 'UserKnownHostsFile %s\n' % 99 | (ssh_identity, os.path.join(root, 'known_hosts'))) 100 | 101 | os.environ['GIT_SSH_COMMAND'] = 'ssh -F %s' % ssh_config 102 | 103 | 104 | def lambda_handler(event, context): 105 | if 'Records' in event: 106 | return handle_sns(event, context) 107 | elif 'body' in event: 108 | return handle_apigw(event, context) 109 | raise ValueError("Did not recognize a valid event originated by SNS nor " 110 | "by API Gateway. Did you configure it correctly?") 111 | -------------------------------------------------------------------------------- /nizkctf/cli/scoreboard.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import sys 6 | import json 7 | import operator 8 | import tempfile 9 | import subprocess 10 | import codecs 11 | from ..team import Team 12 | from ..text import width 13 | from ..six import viewitems 14 | from ..acceptedsubmissions import AcceptedSubmissions, TIME_FORMAT 15 | 16 | 17 | def rank(): 18 | ''' 19 | Compute ranking given a submissions file. 20 | 21 | Args: 22 | f (file): File-like object with the submissions json. 23 | 24 | Returns: 25 | List containing the computed ranking for the submission list. 26 | The result is in the following format: 27 | 28 | (team, score) 29 | 30 | And a map containing submissions sorted by team. 31 | ''' 32 | 33 | submissions = {} 34 | scores = {} 35 | last_submission = {} 36 | for subm_num, subm in enumerate(AcceptedSubmissions()): 37 | team = subm['team'] 38 | score, _ = scores.get(team, (0, None)) 39 | scores[team] = (score + subm['points'], -subm_num) 40 | submissions.setdefault(team, []).append(subm) 41 | 42 | scores = sorted(viewitems(scores), key=operator.itemgetter(1), 43 | reverse=True) 44 | scores = [(team, score) for team, (score, _) in scores] 45 | 46 | return (scores, submissions) 47 | 48 | 49 | def pprint(ranking, top=0, show_names=False): 50 | ''' 51 | Pretty print scoreboard in terminal. 52 | 53 | Args: 54 | ranking (list): List of tuples containing teams and scores. 55 | top (int): Number of teams to show in scoreboard. 56 | 57 | ''' 58 | 59 | if top == 0: 60 | top = len(ranking) 61 | 62 | if len(ranking) == 0: 63 | print('Nobody scored yet.') 64 | return 65 | 66 | ranking = ranking[:top] 67 | 68 | if show_names: 69 | ranking = [(Team(id=team)['name'], score) for team, score in ranking] 70 | 71 | team_len = max(width(team) for team, score in ranking) 72 | team_len = max(team_len, 10) 73 | 74 | pos_len = score_len = 6 75 | 76 | def hyph(n): 77 | return '-'*(n + 2) 78 | 79 | sep = hyph(pos_len) + '+' + hyph(team_len) + '+' + hyph(score_len) 80 | 81 | def fmtcol(s, n): 82 | return ' ' + s + ' '*(n - width(s) + 1) 83 | 84 | def fmt(pos, team, score): 85 | return fmtcol(pos, pos_len) + '|' + \ 86 | fmtcol(team, team_len) + '|' + \ 87 | fmtcol(score, score_len) 88 | 89 | print('') 90 | print(sep) 91 | print(fmt('Pos', 'Team', 'Score')) 92 | print(sep) 93 | 94 | for idx, (team, score) in enumerate(ranking): 95 | pos = '%d' % (idx + 1) 96 | print(fmt(pos, team, '%d' % score)) 97 | 98 | print(sep) 99 | print('') 100 | 101 | 102 | def plot(ranking, submissions, top=3): 103 | ''' 104 | Plot points for top teams. 105 | 106 | Args: 107 | ranking (list): List containing teams and scores sorted in 108 | descending order. 109 | submissions (dict): Dict [team] -> submission list. 110 | top (int): Number of teams to appear in chart. 111 | ''' 112 | # generate temporary files with data points 113 | fnames = [] 114 | for team, _ in ranking[0:top]: 115 | f = tempfile.NamedTemporaryFile(suffix='.dat', 116 | prefix='nizkctf-', delete=True) 117 | w = codecs.getwriter('utf-8')(f) 118 | partial = 0 119 | for subm in submissions[team]: 120 | partial += subm['points'] 121 | w.write('%s, %d\n' % (subm['time'], partial)) 122 | w.flush() 123 | fnames.append((team, f)) 124 | 125 | # generate gnuplot file 126 | f = tempfile.NamedTemporaryFile(suffix='.gp', 127 | prefix='nizkctf-', delete=True) 128 | w = codecs.getwriter('utf-8')(f) 129 | w.write('set terminal dumb 120 30\n') 130 | w.write('set xdata time\n') 131 | w.write('set datafile sep \',\'\n') 132 | w.write('set timefmt "%s"\n' % TIME_FORMAT) 133 | w.write('set style data steps\n') 134 | w.write('plot ') 135 | fmt = '\'%s\' using 1:2 title \'%s\'' 136 | w.write(fmt % (fnames[0][1].name, fnames[0][0])) 137 | for team, ft in fnames[1:]: 138 | w.write(', ') 139 | w.write(fmt % (ft.name, team)) 140 | w.flush() 141 | 142 | # plot in terminal 143 | p = subprocess.Popen(['gnuplot', f.name], 144 | stderr=sys.stderr, 145 | stdout=sys.stdout) 146 | p.wait() 147 | 148 | # close/delete files 149 | f.close() 150 | for nm, f in fnames: 151 | f.close() 152 | -------------------------------------------------------------------------------- /nizkctf/repohost/github.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import requests 6 | import hashlib 7 | import hmac 8 | from ..settings import Settings 9 | from ..six import to_bytes 10 | from .common import BaseRepoHost, APIError, WebhookAuthError 11 | 12 | 13 | class GitHubWebhook(object): 14 | @staticmethod 15 | def auth(secret, headers, raw_payload): 16 | received_sig = to_bytes(headers['X-Hub-Signature']) 17 | 18 | h = hmac.new(secret, raw_payload, hashlib.sha1).hexdigest() 19 | correct_sig = b'sha1=' + to_bytes(h) 20 | 21 | if not hmac.compare_digest(received_sig, correct_sig): 22 | raise WebhookAuthError() 23 | 24 | @staticmethod 25 | def adapt_payload(payload): 26 | # filtering 27 | if 'pull_request' not in payload: 28 | return None 29 | if payload['action'] not in {'opened', 'reopened'}: 30 | return None 31 | if payload['pull_request']['base']['repo']['full_name'] != \ 32 | Settings.submissions_project: 33 | return None 34 | if payload['pull_request']['base']['ref'] != 'master': 35 | return None 36 | # mappings 37 | return {"mr_id": payload['pull_request']['number'], 38 | "source_ssh_url": payload['pull_request']['head']['repo'] 39 | ['ssh_url'], 40 | "source_commit": payload['pull_request']['head']['sha'], 41 | "user_id": payload['pull_request']['user']['id'], 42 | "username": payload['pull_request']['user']['login']} 43 | 44 | 45 | class GitHub(BaseRepoHost): 46 | webhook = GitHubWebhook 47 | 48 | @classmethod 49 | def get_token(cls, username, password): 50 | authorization = {'scopes': 'public_repo', 51 | 'note': Settings.ctf_name} 52 | 53 | r = requests.post(Settings.github_api_endpoint + 54 | 'authorizations', 55 | json=authorization, 56 | auth=(username, password)) 57 | 58 | data = r.json() 59 | 60 | if cls._has_error(data, 'already_exists'): 61 | raise APIError("Please visit https://github.com/settings/tokens " 62 | "and make sure you do not already have a personal " 63 | "access token called '%s'" % Settings.ctf_name) 64 | cls._raise_for_status(r) 65 | 66 | return data['token'] 67 | 68 | @staticmethod 69 | def get_ssh_url(proj): 70 | return Settings.github_ssh_url % proj 71 | 72 | def fork(self, source): 73 | r = self.s.post(Settings.github_api_endpoint + 74 | 'repos/' + source + '/forks') 75 | self._raise_for_status(r) 76 | data = r.json() 77 | return data['full_name'], data['ssh_url'] 78 | 79 | def merge_request(self, source, target, 80 | source_branch='master', 81 | target_branch='master', 82 | title='Pull Request'): 83 | source_branch = source.split('/', 2)[0] + ':' + source_branch 84 | 85 | pull_request = {'head': source_branch, 86 | 'base': target_branch, 87 | 'title': title} 88 | 89 | r = self.s.post(Settings.github_api_endpoint + 90 | 'repos/' + target + '/pulls', 91 | json=pull_request) 92 | self._raise_for_status(r) 93 | return r.json() 94 | 95 | def mr_comment(self, proj, mr_id, contents): 96 | r = self.s.post(Settings.github_api_endpoint + 97 | 'repos/' + proj + '/issues/%d' % mr_id + '/comments', 98 | json={'body': contents}) 99 | self._raise_for_status(r) 100 | return r.json() 101 | 102 | def mr_close(self, proj, mr_id): 103 | r = self.s.patch(Settings.github_api_endpoint + 104 | 'repos/' + proj + '/pulls/%d' % mr_id, 105 | json={'state': 'closed'}) 106 | self._raise_for_status(r) 107 | return r.json() 108 | 109 | def mr_accept(self, proj, mr_id, sha): 110 | r = self.s.put(Settings.github_api_endpoint + 111 | 'repos/' + proj + '/pulls/%d' % mr_id + '/merge', 112 | json={'sha': sha}) 113 | self._raise_for_status(r) 114 | return r.json() 115 | 116 | def _init_session(self): 117 | self.s.headers.update({'Authorization': 'token ' + self.token}) 118 | 119 | @staticmethod 120 | def _has_error(data, err_code): 121 | return any(err.get('code') == err_code 122 | for err in data.get('errors', [])) 123 | -------------------------------------------------------------------------------- /nizkctf/team.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import re 7 | import hashlib 8 | import pysodium 9 | from .six import text_type 10 | from .settings import Settings 11 | from .subrepo import SubRepo 12 | from .serializable import SerializableDict, SerializableList 13 | from .proof import proof_open 14 | from .cli.teamsecrets import TeamSecrets 15 | 16 | 17 | TEAM_FILE = 'team.json' 18 | MEMBERS_FILE = 'members.json' 19 | SUBMISSIONS_FILE = 'submissions.csv' 20 | 21 | 22 | class Team(SerializableDict): 23 | def __init__(self, name=None, id=None): 24 | if name: 25 | id = self.name_to_id(name) 26 | self.update({'name': name}) 27 | if id: 28 | self.validate_id(id) 29 | self.id = id 30 | else: 31 | raise ValueError('Either name or id are required') 32 | 33 | super(Team, self).__init__() 34 | 35 | if self.exists(): 36 | self.validate() 37 | 38 | def dir(self): 39 | return SubRepo.get_path(self.id) 40 | 41 | def path(self): 42 | return os.path.join(self.dir(), TEAM_FILE) 43 | 44 | def save(self): 45 | if not self.exists(): 46 | os.makedirs(self.dir()) 47 | super(Team, self).save() 48 | 49 | def members(self): 50 | return TeamMembers(self) 51 | 52 | def submissions(self): 53 | return TeamSubmissions(self) 54 | 55 | @staticmethod 56 | def name_to_id(name): 57 | assert isinstance(name, text_type) 58 | sha = hashlib.sha256(name.encode('utf-8')).hexdigest() 59 | return sha[0:1] + '/' + sha[1:4] + '/' + sha[4:] 60 | 61 | @staticmethod 62 | def validate_id(id): 63 | assert isinstance(id, text_type) 64 | if not re.match(r'^[0-9a-f]/[0-9a-f]{3}/[0-9a-f]{60}$', id): 65 | raise ValueError('Invalid Team ID') 66 | 67 | @staticmethod 68 | def _binary_field(k): 69 | return k.endswith('_pk') 70 | 71 | def validate(self): 72 | expected_keys = {'name', 'crypt_pk', 'sign_pk'} 73 | if set(self.keys()) != expected_keys: 74 | raise ValueError("Team should contain, and only contain: %s" % 75 | ', '.join(expected_keys)) 76 | 77 | assert isinstance(self['name'], text_type) 78 | if len(self['name']) > Settings.max_size_team_name: 79 | raise ValueError("Team name must have at most %d chars." % 80 | Settings.max_size_team_name) 81 | if self.name_to_id(self['name']) != self.id: 82 | raise ValueError("Team name does not match its ID") 83 | 84 | assert isinstance(self['crypt_pk'], bytes) 85 | if len(self['crypt_pk']) != pysodium.crypto_box_PUBLICKEYBYTES: 86 | raise ValueError("Team's crypt_pk has incorrect size") 87 | 88 | assert isinstance(self['sign_pk'], bytes) 89 | if len(self['sign_pk']) != pysodium.crypto_sign_PUBLICKEYBYTES: 90 | raise ValueError("Team's sign_pk has incorrect size") 91 | 92 | 93 | class TeamMembers(SerializableList): 94 | def __init__(self, team): 95 | self.team = team 96 | self.team_dir = team.dir() 97 | super(TeamMembers, self).__init__() 98 | 99 | def path(self): 100 | return os.path.join(self.team_dir, MEMBERS_FILE) 101 | 102 | def projection(self, attr): 103 | return [member[attr] for member in self] 104 | 105 | def add(self, id=None, username=None): 106 | assert isinstance(id, int) or isinstance(id, long) 107 | assert isinstance(username, text_type) 108 | 109 | another_team = lookup_member(id=id) 110 | if another_team: 111 | if another_team != self.team: 112 | raise ValueError("User '%s' is already member of team '%s'" % 113 | (username, another_team['name'])) 114 | else: 115 | # do nothing, but do not fail if it is the same team 116 | return 117 | 118 | self.append({'id': id, 'username': username}) 119 | self.save() 120 | 121 | 122 | class TeamSubmissions(object): 123 | def __init__(self, team): 124 | self.team = team 125 | self.path = os.path.join(team.dir(), SUBMISSIONS_FILE) 126 | 127 | def submit(self, proof): 128 | with open(self.path, 'a') as f: 129 | f.write(proof + b'\n') 130 | 131 | def challs(self): 132 | r = [] 133 | if os.path.exists(self.path): 134 | with open(self.path) as f: 135 | for proof in f: 136 | r.append(proof_open(self.team, proof.strip())) 137 | if len(set(r)) != len(r): 138 | raise ValueError('Team submissions contain repeated challenges') 139 | return r 140 | 141 | 142 | def my_team(): 143 | return Team(id=TeamSecrets['id']) 144 | 145 | 146 | def all_teams(): 147 | root = SubRepo.get_path() 148 | for path, dirs, files in os.walk(root): 149 | if TEAM_FILE in files: 150 | assert path.startswith(root) 151 | id = path[len(root):].strip('/') 152 | yield Team(id=id) 153 | 154 | 155 | def lookup_member(id=None, username=None): 156 | if id: 157 | attr = 'id' 158 | value = id 159 | elif username: 160 | attr = 'username' 161 | value = username 162 | else: 163 | raise ValueError('Provide either an id or an username') 164 | 165 | for team in all_teams(): 166 | if value in team.members().projection(attr): 167 | return team 168 | 169 | return None 170 | -------------------------------------------------------------------------------- /nizkctf/repohost/gitlab.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import requests 6 | import hmac 7 | import re 8 | from ..settings import Settings 9 | from ..six import to_bytes 10 | from .common import BaseRepoHost, APIError, WebhookAuthError, quote_plus 11 | 12 | 13 | class GitLabWebhook(object): 14 | @staticmethod 15 | def auth(secret, headers, raw_payload): 16 | received_token = to_bytes(headers['X-Gitlab-Token']) 17 | if not hmac.compare_digest(secret, received_token): 18 | raise WebhookAuthError() 19 | 20 | @staticmethod 21 | def adapt_payload(payload): 22 | # filtering 23 | if payload['object_kind'] != 'merge_request': 24 | return None 25 | if payload['object_attributes']['action'] not in {'open', 'reopen'}: 26 | return None 27 | if payload['object_attributes']['target']['path_with_namespace'] != \ 28 | Settings.submissions_project: 29 | return None 30 | if payload['object_attributes']['target_branch'] != 'master': 31 | return None 32 | # mappings 33 | return {"mr_id": payload['object_attributes']['id'], 34 | "source_ssh_url": payload['object_attributes']['source'] 35 | ['git_ssh_url'], 36 | "source_commit": payload['object_attributes']['last_commit'] 37 | ['id'], 38 | "user_id": payload['object_attributes']['author_id'], 39 | "username": payload['user']['username']} 40 | 41 | 42 | class GitLab(BaseRepoHost): 43 | webhook = GitLabWebhook 44 | 45 | @classmethod 46 | def get_token(cls, username, password): 47 | auth = {'login': username, 48 | 'password': password} 49 | 50 | r = requests.post(Settings.gitlab_api_endpoint + 51 | 'session', 52 | json=auth) 53 | cls._raise_for_status(r) 54 | 55 | data = r.json() 56 | return data['private_token'] 57 | 58 | @staticmethod 59 | def get_ssh_url(proj): 60 | return Settings.gitlab_ssh_url % proj 61 | 62 | def fork(self, source): 63 | r = self.s.post(Settings.gitlab_api_endpoint + 64 | 'projects/fork/' + quote_plus(source)) 65 | 66 | data = r.json() 67 | if self._has_error(data, 'name', 'has already been taken'): 68 | # Simulate GitHub API behaviour (return already existing fork) 69 | username = self._get_user_namespace() 70 | sink = username + '/' + source.split('/', 2)[1] 71 | sink_proj = self._get_project(sink) 72 | forked_from = sink_proj.get('forked_from_project', {})\ 73 | .get('path_with_namespace') 74 | if forked_from != source: 75 | raise APIError("Project '%s' already exists and is not a fork " 76 | "from '%s'. Please remove or rename it to " 77 | "allow a fork to be made." % (sink, source)) 78 | data = sink_proj 79 | else: 80 | self._raise_for_status(r) 81 | 82 | return data['path_with_namespace'], data['ssh_url_to_repo'] 83 | 84 | def merge_request(self, source, target, 85 | source_branch='master', 86 | target_branch='master', 87 | title='Merge Request'): 88 | target_id = self._get_project(target)['id'] 89 | 90 | merge_request = {'source_branch': source_branch, 91 | 'target_branch': target_branch, 92 | 'title': title, 93 | 'target_project_id': target_id} 94 | 95 | r = self.s.post(Settings.gitlab_api_endpoint + 96 | 'projects/' + quote_plus(source) + '/merge_requests', 97 | json=merge_request) 98 | self._raise_for_status(r) 99 | return r.json() 100 | 101 | def mr_comment(self, proj, mr_id, contents): 102 | r = self.s.post(Settings.gitlab_api_endpoint + 103 | 'projects/' + quote_plus(proj) + 104 | '/merge_requests/%d' % mr_id + '/notes', 105 | json={'body': contents}) 106 | self._raise_for_status(r) 107 | return r.json() 108 | 109 | def mr_close(self, proj, mr_id): 110 | r = self.s.put(Settings.gitlab_api_endpoint + 111 | 'projects/' + quote_plus(proj) + 112 | '/merge_requests/%d' % mr_id, 113 | json={'state_event': 'close'}) 114 | self._raise_for_status(r) 115 | return r.json() 116 | 117 | def mr_accept(self, proj, mr_id, sha): 118 | r = self.s.put(Settings.gitlab_api_endpoint + 119 | 'projects/' + quote_plus(proj) + 120 | '/merge_requests/%d' % mr_id + '/merge', 121 | json={'sha': sha}) 122 | self._raise_for_status(r) 123 | return r.json() 124 | 125 | def _get_user_namespace(self): 126 | return (n['path'] for n in self._get_namespaces() 127 | if n['kind'] == 'user').next() 128 | 129 | def _get_namespaces(self): 130 | r = self.s.get(Settings.gitlab_api_endpoint + 131 | 'namespaces') 132 | self._raise_for_status(r) 133 | return r.json() 134 | 135 | def _get_project(self, proj): 136 | r = self.s.get(Settings.gitlab_api_endpoint + 137 | 'projects/' + quote_plus(proj)) 138 | self._raise_for_status(r) 139 | return r.json() 140 | 141 | def _init_session(self): 142 | self.s.headers.update({'PRIVATE-TOKEN': self.token}) 143 | 144 | @staticmethod 145 | def _has_error(data, key, msg): 146 | return msg in data.get('message', {}).get(key, []) 147 | -------------------------------------------------------------------------------- /ctf: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | from __future__ import unicode_literals, division, print_function,\ 5 | absolute_import 6 | import os 7 | import sys 8 | import codecs 9 | import argparse 10 | import getpass 11 | import base64 12 | 13 | from nizkctf.cli import scoreboard, challenges, team 14 | from nizkctf.repohost import RepoHost 15 | from nizkctf.subrepo import SubRepo 16 | from nizkctf.challenge import Challenge, derive_keypair, random_salt 17 | from nizkctf.six import to_unicode, PY2 18 | 19 | 20 | def read_opt(msg, opts): 21 | while True: 22 | inp = raw_input(msg) 23 | if inp.strip() in opts: 24 | return inp.strip() 25 | 26 | 27 | def read_auth(opt): 28 | while True: 29 | if opt == '1': 30 | print('Enter your username and password:') 31 | username = raw_input('Username: ') 32 | password = getpass.getpass('Password: ') 33 | RepoHost.login(username=username, password=password) 34 | return True 35 | elif opt == '2': 36 | print('Enter your auth token:') 37 | token = raw_input('Token: ') 38 | RepoHost.login(token=token) 39 | return True 40 | 41 | 42 | def cmd_scoreboard(args): 43 | if args.pull: 44 | SubRepo.pull() 45 | 46 | ranking, submissions = scoreboard.rank() 47 | scoreboard.pprint(ranking, top=args.top, show_names=args.names) 48 | 49 | if args.chart: 50 | scoreboard.plot(ranking, submissions) 51 | 52 | 53 | def cmd_register(args): 54 | team.register(args.name) 55 | 56 | 57 | def cmd_login(args): 58 | if args.token: 59 | RepoHost.login(token=args.token) 60 | elif args.username and args.password: 61 | RepoHost.login(username=args.username, password=args.password) 62 | else: 63 | read_auth('1') 64 | print('Credentials stored') 65 | print('Cloning submissions repository') 66 | SubRepo.clone() 67 | 68 | 69 | def cmd_init(args): 70 | print('NIZKCTF initializing your environment.') 71 | print('First of all, we need your github/gitlab credentials:') 72 | print('[1] auth via username / password') 73 | print('[2] auth with personal access token') 74 | opt = read_opt('>> ', {'1', '2'}) 75 | print('') 76 | 77 | read_auth(opt) 78 | print('') 79 | 80 | print('Cloning submissions repository') 81 | SubRepo.clone() 82 | 83 | print('Do you want to register a new team? [y/n]') 84 | opt = read_opt('>> ', {'y', 'n'}) 85 | if opt == 'y': 86 | print('Enter your team name:') 87 | team_name = raw_input('>> ') 88 | team.register(team_name) 89 | print('') 90 | 91 | print('We are all set!') 92 | 93 | 94 | def cmd_submit(args): 95 | print('Checking flag: %s' % args.flag) 96 | ret, msg = challenges.submit_flag(args.flag, args.chall) 97 | print(msg) 98 | if not ret: 99 | sys.exit(1) 100 | 101 | 102 | def cmd_challenges(args): 103 | challenges.pprint() 104 | 105 | 106 | def cmd_add_challenge(args): 107 | id = raw_input('Challenge id (digits, letters, underscore): ').strip() 108 | title = raw_input('Title: ').strip() 109 | description = raw_input('Description: ').strip() 110 | points = int(raw_input('Points: ').strip()) 111 | tags = raw_input('Tags (separate tags with space): ').strip().split() 112 | salt = raw_input('Salt (empty string for random salt): ').strip() 113 | if salt == '': 114 | salt = random_salt() 115 | flag = raw_input('Flag: ').strip() 116 | 117 | pk, sk = derive_keypair(salt, flag) 118 | chall = Challenge(id=id) 119 | chall['id'] = id 120 | chall['title'] = title 121 | chall['description'] = description 122 | chall['points'] = points 123 | chall['tags'] = tags 124 | chall['salt'] = salt 125 | chall['pk'] = pk 126 | chall.save() 127 | 128 | 129 | def main(): 130 | if PY2: 131 | sys.argv = map(to_unicode, sys.argv) 132 | 133 | commands = { 134 | 'init': cmd_init, 135 | 'login': cmd_login, 136 | 'score': cmd_scoreboard, 137 | 'register': cmd_register, 138 | 'submit': cmd_submit, 139 | 'challs': cmd_challenges, 140 | 'add': cmd_add_challenge, 141 | } 142 | 143 | parser = argparse.ArgumentParser(description='nizk CTF cli') 144 | subparsers = parser.add_subparsers(help='command help') 145 | 146 | parser_init = subparsers.add_parser('init', help='init ctf environment') 147 | parser_init.set_defaults(command='init') 148 | 149 | parser_login = subparsers.add_parser('login', 150 | help='authenticate in gitlab/github') 151 | parser_login.set_defaults(command='login') 152 | parser_login.add_argument('--username', type=str, default=None, 153 | metavar='USERNAME', 154 | help='username for logging in') 155 | parser_login.add_argument('--password', type=str, default=None, 156 | metavar='PASSWORD', 157 | help='password for logging in') 158 | parser_login.add_argument('--token', type=str, default=None, 159 | metavar='TOKEN', 160 | help='use personal access token instead of ' 161 | 'username/password') 162 | 163 | parser_score = subparsers.add_parser('score', help='scoreboard help') 164 | parser_score.set_defaults(command='score') 165 | parser_score.add_argument('--top', type=int, 166 | default=0, metavar='N', 167 | help='size of ranking to display') 168 | parser_score.add_argument('--pull', action='store_true', 169 | help='pull submissions before displaying scores') 170 | parser_score.add_argument('--names', action='store_true', 171 | help='display team names') 172 | parser_score.add_argument('--chart', action='store_true', 173 | help='display chart') 174 | 175 | parser_register = subparsers.add_parser('register', 176 | help='register a new team') 177 | parser_register.set_defaults(command='register') 178 | parser_register.add_argument('name', metavar='NAME', help='team name') 179 | 180 | parser_submit = subparsers.add_parser('submit', help='submit a flag') 181 | parser_submit.set_defaults(command='submit') 182 | parser_submit.add_argument('flag', metavar='FLAG', help='flag') 183 | parser_submit.add_argument('--chall', type=str, default=None, 184 | metavar='CHALL_ID', help='challenge id') 185 | 186 | parser_challenges = subparsers.add_parser('challs', help='list challenges') 187 | parser_challenges.set_defaults(command='challs') 188 | 189 | parser_add_challenge = subparsers.add_parser('add', help='add challenge') 190 | parser_add_challenge.set_defaults(command='add') 191 | 192 | args = parser.parse_args() 193 | commands[args.command](args) 194 | 195 | if __name__ == '__main__': 196 | main() 197 | -------------------------------------------------------------------------------- /nizkctf/proposal.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import re 7 | import subprocess 8 | from .settings import Settings 9 | from .repohost import RepoHost 10 | from .subrepo import SubRepo 11 | from .team import Team, TEAM_FILE, SUBMISSIONS_FILE 12 | from .acceptedsubmissions import AcceptedSubmissions 13 | 14 | 15 | DIFF_MAX_SIZE = 5000 16 | PUSH_RETRIES = 5 17 | 18 | 19 | def consider_proposal(merge_info): 20 | # Clone official repository 21 | SubRepo.clone(fork=False) 22 | 23 | # Set CI user name / email 24 | setup_user_name_and_email() 25 | 26 | # Fetch proposal 27 | add_proposal_remote(merge_info) 28 | 29 | # Get commits between which to compute diffs 30 | commit = merge_info['source_commit'] 31 | merge_base = get_merge_base(commit) 32 | 33 | # Check if there is a single commit in the merge request 34 | check_rev_count(merge_base, commit) 35 | # Check if the diff is not too big 36 | check_diff_size(merge_base, commit) 37 | # Check if only allowed ops were done (add/modify) 38 | check_no_unallowed_ops(merge_base, commit) 39 | # Validate and get files added/modified 40 | added_file = get_added_file(merge_base, commit) 41 | modified_file = get_modified_file(merge_base, commit) 42 | 43 | if added_file and modified_file: 44 | raise ValueError("We only allow commits doing a single operation") 45 | 46 | changed_file = added_file or modified_file 47 | if not changed_file: 48 | raise ValueError("You managed to make a commit which does nothing") 49 | changed_basename = os.path.basename(changed_file) 50 | 51 | if added_file and changed_basename == TEAM_FILE: 52 | team_registration(merge_info, added_file) 53 | elif changed_file and changed_basename == SUBMISSIONS_FILE: 54 | flag_submission(merge_info, changed_file) 55 | else: 56 | raise ValueError("unrecognized operation") 57 | 58 | 59 | def team_registration(merge_info, added_file): 60 | # Checkout first to get the new team file 61 | commit = merge_info['source_commit'] 62 | checkout(commit) 63 | 64 | team = filename_owner(added_file) 65 | team.validate() 66 | 67 | def local_changes(): 68 | # Back to branch, do local modifications 69 | checkout('master') 70 | add_member(team, merge_info) 71 | 72 | local_changes() # Validate local modifications before accepting MR 73 | accept_proposal(merge_info) 74 | 75 | for _ in retry_push('Add member who registered team'): 76 | local_changes() 77 | 78 | 79 | def flag_submission(merge_info, modified_file): 80 | team = filename_owner(modified_file) 81 | challs_before = set(team.submissions().challs()) 82 | 83 | # Checkout to get the newly submitted challenge 84 | commit = merge_info['source_commit'] 85 | checkout(commit) 86 | 87 | challs_after = set(team.submissions().challs()) 88 | 89 | new_challs = challs_after - challs_before 90 | assert len(new_challs) == 1 91 | chall, = new_challs 92 | 93 | def local_changes(): 94 | # Back to branch, do local modifications 95 | checkout('master') 96 | add_member(team, merge_info) 97 | AcceptedSubmissions().add(chall.id, chall['points'], team.id) 98 | 99 | local_changes() # Validate local modifications before accepting MR 100 | accept_proposal(merge_info) 101 | 102 | for _ in retry_push('Accept challenge solution'): 103 | local_changes() 104 | 105 | 106 | def add_member(team, merge_info): 107 | team_dir = team.dir() 108 | if not os.path.exists(team_dir): 109 | os.makedirs(team_dir) 110 | 111 | team.members().add(id=merge_info['user_id'], 112 | username=merge_info['username']) 113 | 114 | 115 | def accept_proposal(merge_info): 116 | proj = Settings.submissions_project 117 | mr_id = merge_info['mr_id'] 118 | commit = merge_info['source_commit'] 119 | 120 | repohost = RepoHost.instance() 121 | repohost.mr_accept(proj, mr_id, commit) 122 | 123 | 124 | def retry_push(commit_message, retries=PUSH_RETRIES): 125 | for retry in range(retries): 126 | try: 127 | checkout('master') 128 | SubRepo.git(['reset', '--hard', 'upstream/master']) 129 | SubRepo.pull() 130 | yield retry # do local modifications 131 | SubRepo.push(commit_message, merge_request=False) 132 | break 133 | except: 134 | if retry == retries - 1: 135 | raise 136 | 137 | 138 | def filename_owner(filename): 139 | team_id, basename = os.path.split(filename) 140 | return Team(id=team_id) 141 | 142 | 143 | def add_proposal_remote(merge_info): 144 | url = merge_info['source_ssh_url'] 145 | SubRepo.git(['remote', 'add', 'proposal', url]) 146 | SubRepo.git(['fetch', '--all']) 147 | 148 | 149 | def setup_user_name_and_email(): 150 | ci_user_name = os.getenv('CI_USER_NAME') 151 | ci_user_email = os.getenv('CI_USER_EMAIL') 152 | if ci_user_name: 153 | SubRepo.git(['config', 'user.name', ci_user_name]) 154 | if ci_user_email: 155 | SubRepo.git(['config', 'user.email', ci_user_email]) 156 | 157 | 158 | def checkout(commit): 159 | SubRepo.git(['checkout', commit]) 160 | 161 | 162 | def get_added_file(src, dest): 163 | return get_file(src, dest, 'A', {TEAM_FILE, SUBMISSIONS_FILE}) 164 | 165 | 166 | def get_modified_file(src, dest): 167 | return get_file(src, dest, 'M', {SUBMISSIONS_FILE}) 168 | 169 | 170 | def get_file(src, dest, filt, whitelist): 171 | stats = diff_stats(src, dest, ['--diff-filter=' + filt]) 172 | if len(stats) == 0: 173 | return None 174 | if len(stats) != 1: 175 | raise ValueError("We only allow a single file to be added or modified " 176 | "per commit") 177 | 178 | stat, = stats 179 | lines_added, lines_removed, filename = stat 180 | if lines_removed != 0: 181 | raise ValueError("We do not allow lines to be removed from files") 182 | if lines_added != 1: 183 | raise ValueError("Changes can only add a single line to a file") 184 | 185 | check_whitelist(filename, whitelist) 186 | return filename 187 | 188 | 189 | def check_no_unallowed_ops(src, dest): 190 | stats = diff_stats(src, dest, ['--diff-filter=am']) 191 | if len(stats) != 0: 192 | raise ValueError("We only allow files to be added or modified") 193 | 194 | 195 | def check_whitelist(filename, whitelist): 196 | basename = os.path.basename(filename) 197 | if basename not in whitelist: 198 | raise ValueError("Filename '%s' not in the whitelist" % basename) 199 | 200 | 201 | def diff_stats(src, dest, args=[]): 202 | stats = SubRepo.git(['diff', '--numstat'] + args + [src, dest], 203 | stdout=subprocess.PIPE) 204 | lines = [re.split(r'\s+', line.strip(), 2) for line in 205 | stats.split('\n')] 206 | lines = [line for line in lines if line != ['']] 207 | return [(int(lines_added), int(lines_removed), filename) 208 | for lines_added, lines_removed, filename 209 | in lines] 210 | 211 | 212 | def check_rev_count(src, dest): 213 | revs = int(SubRepo.git(['rev-list', '--count', src+'...'+dest], 214 | stdout=subprocess.PIPE).strip()) 215 | 216 | if revs != 1: 217 | raise ValueError("We only accept a single commit per merge request") 218 | 219 | 220 | def check_diff_size(src, dest): 221 | diff = SubRepo.git(['diff', '--no-color', '-U0', src, dest], 222 | stdout=subprocess.PIPE) 223 | 224 | if len(diff) > DIFF_MAX_SIZE: 225 | raise ValueError("Diff size (%d bytes) is above the maximum permitted " 226 | "(%d bytes)" % (len(diff), DIFF_MAX_SIZE)) 227 | 228 | 229 | def get_merge_base(commit): 230 | merge_base = SubRepo.git(['merge-base', 'upstream/master', commit], 231 | stdout=subprocess.PIPE).strip() 232 | return merge_base 233 | --------------------------------------------------------------------------------