ctf-attackapi 0.2.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/PKG-INFO +45 -11
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/README.md +44 -10
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/pyproject.toml +1 -1
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/pyproject.toml.orig +1 -1
- ctf_attackapi-0.3.0/src/attackapi/__init__.py +4 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/async_api/decoders.py +1 -1
- ctf_attackapi-0.3.0/src/attackapi/models.py +189 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/server.py +14 -14
- ctf_attackapi-0.2.2/src/attackapi/__init__.py +0 -4
- ctf_attackapi-0.2.2/src/attackapi/models.py +0 -144
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/async_api/__init__.py +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/async_api/api.py +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/async_api/filelock.py +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/functional.py +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/py.typed +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/__init__.py +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/__main__.py +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/docs.py +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/worker.py +0 -0
- {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/sync_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctf-attackapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached.
|
|
5
5
|
Keywords: Attack-Defense,CTF,Attack API,Attack Info,Flag IDs,FAUST CTF,ENOWARS,saarCTF
|
|
6
6
|
Author: Markus Bauer
|
|
@@ -54,6 +54,31 @@ Downloading that file for every exploit you're firing is costing time and bandwi
|
|
|
54
54
|
|
|
55
55
|
This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
|
|
56
56
|
|
|
57
|
+
Changes in 0.3.0
|
|
58
|
+
----------------
|
|
59
|
+
|
|
60
|
+
- `flag_id_flat()` is now `flag_ids()`, and `flag_id_raw()` is `flag_ids_raw()`. The list of
|
|
61
|
+
strings is what an exploit wants essentially every time, so it gets the plain name, and only the
|
|
62
|
+
one that hands back the game's own structure carries a suffix. The `attack_info_*` aliases are
|
|
63
|
+
gone with them: flag IDs is what most games call these, so the package calls them that once.
|
|
64
|
+
The REST API follows -- `/api/v1/flag_ids/...` and `/api/v1/flag_ids_raw/...`, returning a
|
|
65
|
+
`flag_ids` key.
|
|
66
|
+
- Lookups never raise. `flag_ids()` returns `[]` and `flag_ids_raw()` returns `None` for
|
|
67
|
+
anything they cannot answer, including a `None` team -- so the common
|
|
68
|
+
`flag_ids(service, info.team(name))` no longer dies on a name that did not resolve.
|
|
69
|
+
- The mistakes that are wrong on *every* call -- an unknown service or team, a team that never
|
|
70
|
+
resolved, a team of a type the API never took -- now raise a `UserWarning` pointing at your
|
|
71
|
+
line, instead of being indistinguishable from a team that simply has no flag IDs yet. The
|
|
72
|
+
ordinary case stays silent: a known team with nothing published this round is not your mistake.
|
|
73
|
+
Silence the warnings with `warnings.simplefilter("ignore")` if your exploit prefers it.
|
|
74
|
+
- Both take an optional `round`, counting back from the newest published round when negative
|
|
75
|
+
(`-1` is the newest). The default is unchanged and still returns every round the game API
|
|
76
|
+
published -- it only publishes the rounds whose flags are still valid, so narrowing by default
|
|
77
|
+
would cost you flags. The selector is public as `attackapi.select_round()`, next to
|
|
78
|
+
`flatten_flag_ids()`.
|
|
79
|
+
- New `has_service()`, for the case-insensitive "does this game have that service?" check that
|
|
80
|
+
previously meant reaching into the `flag_ids` field. That field is now private.
|
|
81
|
+
|
|
57
82
|
Changes in 0.2.0
|
|
58
83
|
----------------
|
|
59
84
|
|
|
@@ -61,7 +86,7 @@ Changes in 0.2.0
|
|
|
61
86
|
instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
|
|
62
87
|
dialects are unchanged.
|
|
63
88
|
- `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
|
|
64
|
-
- The helper behind `flag_id_flat()` is public as `attackapi.flatten_flag_ids()`, for callers that
|
|
89
|
+
- The helper behind `flag_id_flat()` (now `flag_ids()`) is public as `attackapi.flatten_flag_ids()`, for callers that
|
|
65
90
|
flatten a subset of the raw structure themselves.
|
|
66
91
|
- `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
|
|
67
92
|
no ID for a round is a hole, not a value.
|
|
@@ -98,7 +123,7 @@ from attackapi import *
|
|
|
98
123
|
# 1. Set the API URL in code (or use CTF_API environment variable)
|
|
99
124
|
configure("https://scoreboard.ctf.saarland/api/attack.json")
|
|
100
125
|
# 2. Get attack infos!
|
|
101
|
-
for username in attack_info().
|
|
126
|
+
for username in attack_info().flag_ids("servicename", "10.32.1.2"):
|
|
102
127
|
pwn("10.32.1.2", username)
|
|
103
128
|
```
|
|
104
129
|
|
|
@@ -184,23 +209,32 @@ print(info.teams) # list of Team objects
|
|
|
184
209
|
print(info.teams[0].id, info.teams[0].ip, info.teams[0].name) # Team is ID, IP, and optional name
|
|
185
210
|
print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
|
|
186
211
|
|
|
187
|
-
# set of service names
|
|
212
|
+
# set of service names, and a case-insensitive check for one
|
|
188
213
|
print(info.services)
|
|
214
|
+
print(info.has_service("servicename"))
|
|
189
215
|
|
|
190
216
|
# flag format and the round this info was generated for, where the game reports them
|
|
191
217
|
print(info.flag_regex, info.current_round)
|
|
192
218
|
|
|
193
|
-
#
|
|
194
|
-
# team can be ID, IP, or name.
|
|
219
|
+
# flag IDs for a service and team, as a string list -- the same shape whatever game you play.
|
|
220
|
+
# team can be ID, IP, or name.
|
|
221
|
+
print(info.flag_ids("servicename", "10.32.1.2"))
|
|
222
|
+
# => ["abc", "def"]
|
|
223
|
+
|
|
224
|
+
# One round only, for the games that report rounds. -1 is the newest published round.
|
|
225
|
+
print(info.flag_ids("servicename", "10.32.1.2", -1))
|
|
226
|
+
# => ["def"]
|
|
227
|
+
|
|
228
|
+
# the same flag IDs in the game API's own format, when you need the structure it keeps.
|
|
195
229
|
# Return data format is determined by game API.
|
|
196
|
-
print(info.
|
|
230
|
+
print(info.flag_ids_raw("servicename", "10.32.1.2"))
|
|
197
231
|
# => {"227": "abc", "228": "def", ...}
|
|
198
|
-
|
|
199
|
-
# Get flag IDs as string list (independent of game API format, but less precise)
|
|
200
|
-
print(info.flag_id_flat("servicename", "10.32.1.2"))
|
|
201
|
-
# => ["abc", "def"]
|
|
202
232
|
```
|
|
203
233
|
|
|
234
|
+
Nothing above raises. A lookup that cannot be answered gives you `[]` (or `None` for the raw
|
|
235
|
+
form) and warns, so a typo in an exploit costs a line on stderr rather than the round it was in
|
|
236
|
+
the middle of.
|
|
237
|
+
|
|
204
238
|
Server Documentation
|
|
205
239
|
--------------------
|
|
206
240
|
|
|
@@ -20,6 +20,31 @@ Downloading that file for every exploit you're firing is costing time and bandwi
|
|
|
20
20
|
|
|
21
21
|
This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
|
|
22
22
|
|
|
23
|
+
Changes in 0.3.0
|
|
24
|
+
----------------
|
|
25
|
+
|
|
26
|
+
- `flag_id_flat()` is now `flag_ids()`, and `flag_id_raw()` is `flag_ids_raw()`. The list of
|
|
27
|
+
strings is what an exploit wants essentially every time, so it gets the plain name, and only the
|
|
28
|
+
one that hands back the game's own structure carries a suffix. The `attack_info_*` aliases are
|
|
29
|
+
gone with them: flag IDs is what most games call these, so the package calls them that once.
|
|
30
|
+
The REST API follows -- `/api/v1/flag_ids/...` and `/api/v1/flag_ids_raw/...`, returning a
|
|
31
|
+
`flag_ids` key.
|
|
32
|
+
- Lookups never raise. `flag_ids()` returns `[]` and `flag_ids_raw()` returns `None` for
|
|
33
|
+
anything they cannot answer, including a `None` team -- so the common
|
|
34
|
+
`flag_ids(service, info.team(name))` no longer dies on a name that did not resolve.
|
|
35
|
+
- The mistakes that are wrong on *every* call -- an unknown service or team, a team that never
|
|
36
|
+
resolved, a team of a type the API never took -- now raise a `UserWarning` pointing at your
|
|
37
|
+
line, instead of being indistinguishable from a team that simply has no flag IDs yet. The
|
|
38
|
+
ordinary case stays silent: a known team with nothing published this round is not your mistake.
|
|
39
|
+
Silence the warnings with `warnings.simplefilter("ignore")` if your exploit prefers it.
|
|
40
|
+
- Both take an optional `round`, counting back from the newest published round when negative
|
|
41
|
+
(`-1` is the newest). The default is unchanged and still returns every round the game API
|
|
42
|
+
published -- it only publishes the rounds whose flags are still valid, so narrowing by default
|
|
43
|
+
would cost you flags. The selector is public as `attackapi.select_round()`, next to
|
|
44
|
+
`flatten_flag_ids()`.
|
|
45
|
+
- New `has_service()`, for the case-insensitive "does this game have that service?" check that
|
|
46
|
+
previously meant reaching into the `flag_ids` field. That field is now private.
|
|
47
|
+
|
|
23
48
|
Changes in 0.2.0
|
|
24
49
|
----------------
|
|
25
50
|
|
|
@@ -27,7 +52,7 @@ Changes in 0.2.0
|
|
|
27
52
|
instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
|
|
28
53
|
dialects are unchanged.
|
|
29
54
|
- `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
|
|
30
|
-
- The helper behind `flag_id_flat()` is public as `attackapi.flatten_flag_ids()`, for callers that
|
|
55
|
+
- The helper behind `flag_id_flat()` (now `flag_ids()`) is public as `attackapi.flatten_flag_ids()`, for callers that
|
|
31
56
|
flatten a subset of the raw structure themselves.
|
|
32
57
|
- `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
|
|
33
58
|
no ID for a round is a hole, not a value.
|
|
@@ -64,7 +89,7 @@ from attackapi import *
|
|
|
64
89
|
# 1. Set the API URL in code (or use CTF_API environment variable)
|
|
65
90
|
configure("https://scoreboard.ctf.saarland/api/attack.json")
|
|
66
91
|
# 2. Get attack infos!
|
|
67
|
-
for username in attack_info().
|
|
92
|
+
for username in attack_info().flag_ids("servicename", "10.32.1.2"):
|
|
68
93
|
pwn("10.32.1.2", username)
|
|
69
94
|
```
|
|
70
95
|
|
|
@@ -150,23 +175,32 @@ print(info.teams) # list of Team objects
|
|
|
150
175
|
print(info.teams[0].id, info.teams[0].ip, info.teams[0].name) # Team is ID, IP, and optional name
|
|
151
176
|
print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
|
|
152
177
|
|
|
153
|
-
# set of service names
|
|
178
|
+
# set of service names, and a case-insensitive check for one
|
|
154
179
|
print(info.services)
|
|
180
|
+
print(info.has_service("servicename"))
|
|
155
181
|
|
|
156
182
|
# flag format and the round this info was generated for, where the game reports them
|
|
157
183
|
print(info.flag_regex, info.current_round)
|
|
158
184
|
|
|
159
|
-
#
|
|
160
|
-
# team can be ID, IP, or name.
|
|
185
|
+
# flag IDs for a service and team, as a string list -- the same shape whatever game you play.
|
|
186
|
+
# team can be ID, IP, or name.
|
|
187
|
+
print(info.flag_ids("servicename", "10.32.1.2"))
|
|
188
|
+
# => ["abc", "def"]
|
|
189
|
+
|
|
190
|
+
# One round only, for the games that report rounds. -1 is the newest published round.
|
|
191
|
+
print(info.flag_ids("servicename", "10.32.1.2", -1))
|
|
192
|
+
# => ["def"]
|
|
193
|
+
|
|
194
|
+
# the same flag IDs in the game API's own format, when you need the structure it keeps.
|
|
161
195
|
# Return data format is determined by game API.
|
|
162
|
-
print(info.
|
|
196
|
+
print(info.flag_ids_raw("servicename", "10.32.1.2"))
|
|
163
197
|
# => {"227": "abc", "228": "def", ...}
|
|
164
|
-
|
|
165
|
-
# Get flag IDs as string list (independent of game API format, but less precise)
|
|
166
|
-
print(info.flag_id_flat("servicename", "10.32.1.2"))
|
|
167
|
-
# => ["abc", "def"]
|
|
168
198
|
```
|
|
169
199
|
|
|
200
|
+
Nothing above raises. A lookup that cannot be answered gives you `[]` (or `None` for the raw
|
|
201
|
+
form) and warns, so a typo in an exploit costs a line on stderr rather than the round it was in
|
|
202
|
+
the middle of.
|
|
203
|
+
|
|
170
204
|
Server Documentation
|
|
171
205
|
--------------------
|
|
172
206
|
|
|
@@ -171,7 +171,7 @@ class Decoder(GenericDecoder[AttackInfo]):
|
|
|
171
171
|
if not isinstance(flag_ids, dict):
|
|
172
172
|
raise ValueError(f"Invalid flag_ids format for service {service_name!r}: {type(flag_ids)}")
|
|
173
173
|
info.services.add(service_name)
|
|
174
|
-
info.
|
|
174
|
+
info._flag_ids[service_name.lower()] = flag_ids
|
|
175
175
|
|
|
176
176
|
def _parse_teams(self, dialect: Dialect, info: AttackInfo, teams: list) -> None:
|
|
177
177
|
for team in teams:
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from dataclasses import dataclass, field, asdict
|
|
3
|
+
from typing import Optional, Union, Any
|
|
4
|
+
from typing_extensions import TypeAlias
|
|
5
|
+
|
|
6
|
+
RawFlagIds: TypeAlias = Union[list, dict[str, Union[str, list, dict]]]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def flatten_flag_ids(flag_ids: Any) -> list[str]:
|
|
10
|
+
"""
|
|
11
|
+
Collect every non-null scalar flag ID out of an arbitrarily nested flag-ID structure.
|
|
12
|
+
Independent of the game API's exact nesting, but loses the round / flag-store structure.
|
|
13
|
+
"""
|
|
14
|
+
if flag_ids is None: # a flag store with no ID for this round/team
|
|
15
|
+
return []
|
|
16
|
+
if isinstance(flag_ids, str):
|
|
17
|
+
return [flag_ids]
|
|
18
|
+
if isinstance(flag_ids, list):
|
|
19
|
+
result = []
|
|
20
|
+
for value in flag_ids:
|
|
21
|
+
result += flatten_flag_ids(value)
|
|
22
|
+
return result
|
|
23
|
+
if isinstance(flag_ids, dict):
|
|
24
|
+
result = []
|
|
25
|
+
for value in flag_ids.values():
|
|
26
|
+
result += flatten_flag_ids(value)
|
|
27
|
+
return result
|
|
28
|
+
return [str(flag_ids)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def select_round(flag_ids: Any, round: int) -> Any:
|
|
32
|
+
"""
|
|
33
|
+
Restrict flag IDs to a single round. Every game that reports a round keys its per-team attack
|
|
34
|
+
info by it; FAUST CTF does not have the dimension at all, and its plain list is returned
|
|
35
|
+
unchanged rather than sliced into something that only looks like a round.
|
|
36
|
+
|
|
37
|
+
:param flag_ids: raw flag IDs for one service and team, as returned by flag_ids_raw()
|
|
38
|
+
:param round: a round number, or an offset from the newest published round (-1 = newest)
|
|
39
|
+
:return: the same structure, holding at most the requested round
|
|
40
|
+
"""
|
|
41
|
+
if not isinstance(flag_ids, dict):
|
|
42
|
+
return flag_ids
|
|
43
|
+
keys = list(flag_ids.keys())
|
|
44
|
+
if round < 0:
|
|
45
|
+
# the game API publishes a window of recent rounds, in no guaranteed order
|
|
46
|
+
if all(isinstance(key, str) and key.lstrip("-").isdigit() for key in keys):
|
|
47
|
+
keys.sort(key=int)
|
|
48
|
+
if -round > len(keys):
|
|
49
|
+
return {}
|
|
50
|
+
key = keys[round]
|
|
51
|
+
else:
|
|
52
|
+
key = str(round)
|
|
53
|
+
return {key: flag_ids[key]} if key in flag_ids else {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Team:
|
|
58
|
+
"""
|
|
59
|
+
An attackable team.
|
|
60
|
+
IP is given for every game, ID is given or inferred for all known CTFs.
|
|
61
|
+
Name is not present everywhere.
|
|
62
|
+
"""
|
|
63
|
+
id: int
|
|
64
|
+
ip: str
|
|
65
|
+
name: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict:
|
|
68
|
+
return asdict(self)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class AttackInfo:
|
|
73
|
+
"""
|
|
74
|
+
Container for all attack info parsed from the game API.
|
|
75
|
+
Use methods team(), flag_ids(), and flag_ids_raw() to look up data.
|
|
76
|
+
Fields teams and services can be iterated.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
teams: list[Team] = field(default_factory=list)
|
|
80
|
+
team_lookup: dict[str, Team] = field(default_factory=dict)
|
|
81
|
+
services: set[str] = field(default_factory=set)
|
|
82
|
+
_flag_ids: dict[str, dict[str, RawFlagIds]] = field(default_factory=dict)
|
|
83
|
+
flag_regex: Optional[str] = None
|
|
84
|
+
current_round: Optional[int] = None
|
|
85
|
+
raw: bytes = b"" # everything, as given by the game API
|
|
86
|
+
|
|
87
|
+
def team(self, name: Union[str, int]) -> Optional[Team]:
|
|
88
|
+
"""
|
|
89
|
+
Find a team.
|
|
90
|
+
|
|
91
|
+
:param name: Team ID, IP, or name (as far as supported by the API)
|
|
92
|
+
:return:
|
|
93
|
+
"""
|
|
94
|
+
return self.team_lookup.get(str(name).lower())
|
|
95
|
+
|
|
96
|
+
def has_service(self, service: str) -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Whether this game has a service by that name (case insensitive).
|
|
99
|
+
|
|
100
|
+
:param service: Name of a service
|
|
101
|
+
:return:
|
|
102
|
+
"""
|
|
103
|
+
return service.lower() in self._flag_ids
|
|
104
|
+
|
|
105
|
+
def flag_ids(self, service: str, team: Union[str, int, Team, None],
|
|
106
|
+
round: Optional[int] = None) -> list[str]:
|
|
107
|
+
"""
|
|
108
|
+
Find flag IDs for a service and team, as a simple string list -- the same list whatever
|
|
109
|
+
game you are playing, and what an exploit almost always wants.
|
|
110
|
+
|
|
111
|
+
Never raises: a lookup that cannot be answered warns and returns [], so a typo in an
|
|
112
|
+
exploit costs a warning on stderr rather than the round it was in the middle of.
|
|
113
|
+
|
|
114
|
+
:param service: Name of a service (case insensitive, see field "services" for a list of valid names)
|
|
115
|
+
:param team: Team ID, IP, name, or instance (from .team(...))
|
|
116
|
+
:param round: Restrict the result to one round, or to an offset from the newest published
|
|
117
|
+
round (-1 = newest). Defaults to every round the game API published.
|
|
118
|
+
:return:
|
|
119
|
+
"""
|
|
120
|
+
flag_ids = self._lookup(service, team, round)
|
|
121
|
+
return flatten_flag_ids(flag_ids) if flag_ids is not None else []
|
|
122
|
+
|
|
123
|
+
def flag_ids_raw(self, service: str, team: Union[str, int, Team, None],
|
|
124
|
+
round: Optional[int] = None) -> Optional[RawFlagIds]:
|
|
125
|
+
"""
|
|
126
|
+
Find flag IDs for a service and team, in the game API's own format -- for callers that
|
|
127
|
+
need the structure flag_ids() flattens away. That format differs per game.
|
|
128
|
+
|
|
129
|
+
Never raises, and returns None for anything it cannot answer -- see flag_ids().
|
|
130
|
+
|
|
131
|
+
:param service: Name of a service (case insensitive, see field "services" for a list of valid names)
|
|
132
|
+
:param team: Team ID, IP, name, or instance (from .team(...))
|
|
133
|
+
:param round: Restrict the result to one round, or to an offset from the newest published
|
|
134
|
+
round (-1 = newest). Defaults to every round the game API published.
|
|
135
|
+
:return:
|
|
136
|
+
"""
|
|
137
|
+
return self._lookup(service, team, round)
|
|
138
|
+
|
|
139
|
+
def _lookup(self, service: str, team: Union[str, int, Team, None],
|
|
140
|
+
round: Optional[int], stacklevel: int = 3) -> Optional[RawFlagIds]:
|
|
141
|
+
"""
|
|
142
|
+
The lookup behind both accessors. Warns rather than raises on the mistakes that are wrong
|
|
143
|
+
on every call -- an unknown service or team, a team that never resolved -- and stays quiet
|
|
144
|
+
about the ones that are a normal part of a running game.
|
|
145
|
+
|
|
146
|
+
:param stacklevel: frames between this method and the caller to blame in a warning
|
|
147
|
+
"""
|
|
148
|
+
if team is None:
|
|
149
|
+
warnings.warn(f"No team given for service {service!r} - did an earlier team() lookup fail?",
|
|
150
|
+
stacklevel=stacklevel)
|
|
151
|
+
return None
|
|
152
|
+
flag_ids = self._flag_ids.get(service.lower())
|
|
153
|
+
if flag_ids is None:
|
|
154
|
+
warnings.warn(f"Unknown service {service!r}, this game has: {', '.join(sorted(self.services))}",
|
|
155
|
+
stacklevel=stacklevel)
|
|
156
|
+
return None
|
|
157
|
+
raw = self._team_flag_ids(service, flag_ids, team, stacklevel + 1)
|
|
158
|
+
if raw is None or round is None:
|
|
159
|
+
return raw
|
|
160
|
+
return select_round(raw, round)
|
|
161
|
+
|
|
162
|
+
def _team_flag_ids(self, service: str, flag_ids: dict, team: Union[str, int, Team],
|
|
163
|
+
stacklevel: int) -> Optional[RawFlagIds]:
|
|
164
|
+
"""Pick one team out of a service's flag IDs, which the game API keys by ID, IP, or name."""
|
|
165
|
+
if isinstance(team, Team):
|
|
166
|
+
for key in (str(team.id), team.ip, team.name):
|
|
167
|
+
if key is not None and key.lower() in flag_ids:
|
|
168
|
+
return flag_ids[key.lower()]
|
|
169
|
+
# a team the game knows about but has no flag IDs for: the service may be down, or the
|
|
170
|
+
# round's info may not be out yet. Both are ordinary, and neither is worth a warning.
|
|
171
|
+
return None
|
|
172
|
+
if isinstance(team, (str, int)):
|
|
173
|
+
key = str(team).lower()
|
|
174
|
+
if key in flag_ids:
|
|
175
|
+
return flag_ids[key]
|
|
176
|
+
if key in self.team_lookup:
|
|
177
|
+
return self._team_flag_ids(service, flag_ids, self.team_lookup[key], stacklevel)
|
|
178
|
+
warnings.warn(f"Unknown team {team!r} for service {service!r} - not in this attack info "
|
|
179
|
+
f"(a typo, or a team that is offline or banned)", stacklevel=stacklevel)
|
|
180
|
+
return None
|
|
181
|
+
warnings.warn(f"Invalid team type for service {service!r}: {type(team).__name__}: {team!r}",
|
|
182
|
+
stacklevel=stacklevel)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def __str__(self) -> str:
|
|
186
|
+
return repr(self)
|
|
187
|
+
|
|
188
|
+
def __repr__(self) -> str:
|
|
189
|
+
return f"AttackInfo(services={self.services!r}, {len(self.teams)} teams)"
|
|
@@ -21,8 +21,8 @@ class AttackApiViews:
|
|
|
21
21
|
web.get("/api/v1/raw", self.get_raw),
|
|
22
22
|
web.get("/api/v1/services", self.get_services),
|
|
23
23
|
web.get("/api/v1/teams", self.get_teams),
|
|
24
|
-
web.get("/api/v1/
|
|
25
|
-
web.get("/api/v1/
|
|
24
|
+
web.get("/api/v1/flag_ids/{service}/{team}", self.get_flag_ids),
|
|
25
|
+
web.get("/api/v1/flag_ids_raw/{service}/{team}", self.get_flag_ids_raw),
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
async def docs(self, request: web.Request) -> web.Response:
|
|
@@ -38,8 +38,8 @@ class AttackApiViews:
|
|
|
38
38
|
docs = docs.replace('"NOP"', json.dumps(info.teams[0].name))
|
|
39
39
|
if len(info.services) > 0:
|
|
40
40
|
s = list(info.services)[0]
|
|
41
|
-
raw = info.
|
|
42
|
-
flat = info.
|
|
41
|
+
raw = info.flag_ids_raw(s, info.teams[0])
|
|
42
|
+
flat = info.flag_ids(s, info.teams[0])
|
|
43
43
|
if raw:
|
|
44
44
|
docs = docs.replace(
|
|
45
45
|
json.dumps({"227": "username1", "228": "username2", "229": "username3"}),
|
|
@@ -68,31 +68,31 @@ class AttackApiViews:
|
|
|
68
68
|
info = await self._api.attack_info()
|
|
69
69
|
return web.json_response({"services": list(info.services)})
|
|
70
70
|
|
|
71
|
-
async def
|
|
72
|
-
|
|
71
|
+
async def _flag_ids_common(self, request: web.Request,
|
|
72
|
+
cb: Callable[[AttackInfo, str, str], Any]) -> web.Response:
|
|
73
73
|
service = request.match_info["service"]
|
|
74
74
|
team = request.match_info["team"]
|
|
75
75
|
if not service or not team:
|
|
76
76
|
raise web.HTTPBadRequest(reason="Invalid team or service")
|
|
77
77
|
info = await self._api.attack_info()
|
|
78
|
-
if
|
|
78
|
+
if not info.has_service(service):
|
|
79
79
|
raise web.HTTPBadRequest(reason=f"Unknown service, or service has no attack info: {service}")
|
|
80
80
|
flag_ids = cb(info, service, team)
|
|
81
81
|
return web.json_response(
|
|
82
|
-
{"
|
|
82
|
+
{"flag_ids": flag_ids}
|
|
83
83
|
)
|
|
84
84
|
|
|
85
|
-
async def
|
|
85
|
+
async def get_flag_ids(self, request: web.Request) -> web.Response:
|
|
86
86
|
def _cb(info: AttackInfo, service: str, team: str) -> Any:
|
|
87
|
-
return info.
|
|
87
|
+
return info.flag_ids(service, team)
|
|
88
88
|
|
|
89
|
-
return await self.
|
|
89
|
+
return await self._flag_ids_common(request, _cb)
|
|
90
90
|
|
|
91
|
-
async def
|
|
91
|
+
async def get_flag_ids_raw(self, request: web.Request) -> web.Response:
|
|
92
92
|
def _cb(info: AttackInfo, service: str, team: str) -> Any:
|
|
93
|
-
return info.
|
|
93
|
+
return info.flag_ids_raw(service, team)
|
|
94
94
|
|
|
95
|
-
return await self.
|
|
95
|
+
return await self._flag_ids_common(request, _cb)
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
async def create_app() -> web.Application:
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass, field, asdict
|
|
2
|
-
from typing import Optional, Union, Any
|
|
3
|
-
from typing_extensions import TypeAlias
|
|
4
|
-
|
|
5
|
-
RawFlagIds: TypeAlias = Union[list, dict[str, Union[str, list, dict]]]
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def flatten_flag_ids(flag_ids: Any) -> list[str]:
|
|
9
|
-
"""
|
|
10
|
-
Collect every non-null scalar flag ID out of an arbitrarily nested flag-ID structure.
|
|
11
|
-
Independent of the game API's exact nesting, but loses the round / flag-store structure.
|
|
12
|
-
"""
|
|
13
|
-
if flag_ids is None: # a flag store with no ID for this round/team
|
|
14
|
-
return []
|
|
15
|
-
if isinstance(flag_ids, str):
|
|
16
|
-
return [flag_ids]
|
|
17
|
-
if isinstance(flag_ids, list):
|
|
18
|
-
result = []
|
|
19
|
-
for value in flag_ids:
|
|
20
|
-
result += flatten_flag_ids(value)
|
|
21
|
-
return result
|
|
22
|
-
if isinstance(flag_ids, dict):
|
|
23
|
-
result = []
|
|
24
|
-
for value in flag_ids.values():
|
|
25
|
-
result += flatten_flag_ids(value)
|
|
26
|
-
return result
|
|
27
|
-
return [str(flag_ids)]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@dataclass(frozen=True)
|
|
31
|
-
class Team:
|
|
32
|
-
"""
|
|
33
|
-
An attackable team.
|
|
34
|
-
IP is given for every game, ID is given or inferred for all known CTFs.
|
|
35
|
-
Name is not present everywhere.
|
|
36
|
-
"""
|
|
37
|
-
id: int
|
|
38
|
-
ip: str
|
|
39
|
-
name: Optional[str] = None
|
|
40
|
-
|
|
41
|
-
def to_dict(self) -> dict:
|
|
42
|
-
return asdict(self)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@dataclass(frozen=True)
|
|
46
|
-
class AttackInfo:
|
|
47
|
-
"""
|
|
48
|
-
Container for all attack info parsed from the game API.
|
|
49
|
-
Use methods team(), flag_id_raw(), and flag_id_flat() to look up data.
|
|
50
|
-
Fields teams and services can be iterated.
|
|
51
|
-
"""
|
|
52
|
-
|
|
53
|
-
teams: list[Team] = field(default_factory=list)
|
|
54
|
-
team_lookup: dict[str, Team] = field(default_factory=dict)
|
|
55
|
-
services: set[str] = field(default_factory=set)
|
|
56
|
-
flag_ids: dict[str, dict[str, RawFlagIds]] = field(default_factory=dict)
|
|
57
|
-
flag_regex: Optional[str] = None
|
|
58
|
-
current_round: Optional[int] = None
|
|
59
|
-
raw: bytes = b"" # everything, as given by the game API
|
|
60
|
-
|
|
61
|
-
def team(self, name: Union[str, int]) -> Optional[Team]:
|
|
62
|
-
"""
|
|
63
|
-
Find a team.
|
|
64
|
-
|
|
65
|
-
:param name: Team ID, IP, or name (as far as supported by the API)
|
|
66
|
-
:return:
|
|
67
|
-
"""
|
|
68
|
-
return self.team_lookup.get(str(name).lower())
|
|
69
|
-
|
|
70
|
-
def flag_id_raw(self, service: str, team: Union[str, int, Team]) -> Optional[RawFlagIds]:
|
|
71
|
-
"""
|
|
72
|
-
Find flag IDs for a service and team. Flag IDs are returned in the APIs raw format.
|
|
73
|
-
|
|
74
|
-
:param service: Name of a service (case insensitive, see field "services" for a list of valid names)
|
|
75
|
-
:param team: Team ID, IP, name, or instance (from .team(...))
|
|
76
|
-
:return:
|
|
77
|
-
"""
|
|
78
|
-
flag_ids = self.flag_ids.get(service.lower(), {})
|
|
79
|
-
if isinstance(team, Team):
|
|
80
|
-
if str(team.id) in flag_ids:
|
|
81
|
-
return flag_ids[str(team.id)]
|
|
82
|
-
if team.ip is not None and team.ip.lower() in flag_ids:
|
|
83
|
-
return flag_ids[team.ip.lower()]
|
|
84
|
-
if team.name is not None and team.name.lower() in flag_ids:
|
|
85
|
-
return flag_ids[team.name.lower()]
|
|
86
|
-
return None
|
|
87
|
-
elif isinstance(team, str):
|
|
88
|
-
team = team.lower()
|
|
89
|
-
if team in flag_ids:
|
|
90
|
-
return flag_ids.get(team.lower())
|
|
91
|
-
elif team in self.team_lookup:
|
|
92
|
-
return self.flag_id_raw(service, self.team_lookup[team])
|
|
93
|
-
return None
|
|
94
|
-
elif isinstance(team, int):
|
|
95
|
-
if str(team) in flag_ids:
|
|
96
|
-
return flag_ids.get(str(team))
|
|
97
|
-
elif str(team) in self.team_lookup:
|
|
98
|
-
return self.flag_id_raw(service, self.team_lookup[str(team)])
|
|
99
|
-
return None
|
|
100
|
-
|
|
101
|
-
raise ValueError(f"Invalid team type: {type(team)}: {team!r}")
|
|
102
|
-
|
|
103
|
-
def flag_id_flat(self, service: str, team: Union[str, int, Team]) -> list[str]:
|
|
104
|
-
"""
|
|
105
|
-
Find flag IDs for a service and team.
|
|
106
|
-
Flag IDs are returned as a simple string list, containing all attack info for all flag stores.
|
|
107
|
-
|
|
108
|
-
:param service: Name of a service (case insensitive, see field "services" for a list of valid names)
|
|
109
|
-
:param team: Team ID, IP, name, or instance (from .team(...))
|
|
110
|
-
:return:
|
|
111
|
-
"""
|
|
112
|
-
flag_ids = self.flag_id_raw(service, team)
|
|
113
|
-
return flatten_flag_ids(flag_ids) if flag_ids is not None else []
|
|
114
|
-
|
|
115
|
-
def attack_info_raw(self, service: str, team: Union[str, int, Team]) -> Optional[RawFlagIds]:
|
|
116
|
-
"""
|
|
117
|
-
Find attack info for a service and team. Attack info is returned in the APIs raw format.
|
|
118
|
-
|
|
119
|
-
This is an alias for flag_id_raw(...).
|
|
120
|
-
|
|
121
|
-
:param service: Name of a service (case insensitive, see field "services" for a list of valid names)
|
|
122
|
-
:param team: Team ID, IP, name, or instance (from .team(...))
|
|
123
|
-
:return:
|
|
124
|
-
"""
|
|
125
|
-
return self.flag_id_raw(service, team)
|
|
126
|
-
|
|
127
|
-
def attack_info_flat(self, service: str, team: Union[str, int, Team]) -> list[str]:
|
|
128
|
-
"""
|
|
129
|
-
Find attack info for a service and team.
|
|
130
|
-
Attack info is returned as a simple string list, containing all attack info for all flag stores.
|
|
131
|
-
|
|
132
|
-
This is an alias for flag_id_flat(...).
|
|
133
|
-
|
|
134
|
-
:param service: Name of a service (case insensitive, see field "services" for a list of valid names)
|
|
135
|
-
:param team: Team ID, IP, name, or instance (from .team(...))
|
|
136
|
-
:return:
|
|
137
|
-
"""
|
|
138
|
-
return self.flag_id_flat(service, team)
|
|
139
|
-
|
|
140
|
-
def __str__(self) -> str:
|
|
141
|
-
return repr(self)
|
|
142
|
-
|
|
143
|
-
def __repr__(self) -> str:
|
|
144
|
-
return f"AttackInfo(services={self.services!r}, {len(self.teams)} teams)"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|