ctfy.sdk.admin_resources.reports
client.admin.reports — the organiser's view of the deliverable.
1"""``client.admin.reports`` — the organiser's view of the deliverable.""" 2 3from __future__ import annotations 4 5from ctfy.sdk._helpers import _raise_for_status 6from ctfy.sdk.base import BaseHttpClient 7from ctfy.server.models import ( 8 ReportAttachmentsResponse, 9 ReportRosterResponse, 10 ReportVersionsResponse, 11) 12 13 14class AdminReportsResource: 15 """Who has written a report, and what one team actually wrote. 16 17 Split for the same reason ``client.admin.patches`` is: the roster is 18 competition-wide and carries **no bodies** — an event accumulates 19 report versions all week, so a listing that shipped them would read 20 every one out of SQLite to render a table of lengths. :meth:`get` 21 carries them, one team at a time, which is what retaining every 22 version was for. 23 """ 24 25 def __init__(self, http: BaseHttpClient) -> None: 26 self._http = http 27 28 def list(self, competition_id: str) -> ReportRosterResponse: 29 """Every current report's status in one competition. 30 31 One row per ``(team, range)``: a team that wrote up three of the 32 event's four ranges is three rows. 33 34 ``missing_team_count`` is served rather than derived: subtracting 35 the roster from the team list client-side is arithmetic two 36 clients can do differently, and then two organisers disagree 37 about who has not written anything. 38 """ 39 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/reports") 40 _raise_for_status(resp) 41 return ReportRosterResponse.model_validate(resp.json()) 42 43 def get(self, competition_id: str, team_id: str, challenge_id: str) -> ReportVersionsResponse: 44 """One team's whole report history on one range, bodies included. 45 46 ⚠️ The bodies are **attacker-authored text**. Render them as 47 text, never as HTML, and never feed one back into a prompt 48 outside the bounded matching task the grader defines. 49 """ 50 resp = self._http.request( 51 "GET", 52 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}", 53 ) 54 _raise_for_status(resp) 55 return ReportVersionsResponse.model_validate(resp.json()) 56 57 def attachments( 58 self, competition_id: str, team_id: str, challenge_id: str 59 ) -> ReportAttachmentsResponse: 60 """One team's evidence appendix for one range. 61 62 ⚠️ **Empty rather than 404** for a team that uploaded nothing, 63 unlike :meth:`get`. That one distinguishes "wrote nothing" from 64 "you typed the wrong team id"; by the time an organiser is here 65 the team has already been resolved by reading the report, and an 66 appendix with no files is the ordinary case. 67 """ 68 resp = self._http.request( 69 "GET", 70 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/attachments", 71 ) 72 _raise_for_status(resp) 73 return ReportAttachmentsResponse.model_validate(resp.json()) 74 75 def attachment_bytes( 76 self, competition_id: str, team_id: str, challenge_id: str, attachment_id: str 77 ) -> bytes: 78 """Fetch one team's attachment, as an organiser. 79 80 ⚠️ The bytes were chosen by an untrusted uploader. The platform 81 serves them ``nosniff`` + sandboxed and, for anything that is not 82 a real image, as a download — so treat a saved file the way you 83 would treat any artifact recovered from an engagement. 84 """ 85 resp = self._http.request( 86 "GET", 87 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}" 88 f"/attachments/{attachment_id}", 89 ) 90 _raise_for_status(resp) 91 return resp.content
15class AdminReportsResource: 16 """Who has written a report, and what one team actually wrote. 17 18 Split for the same reason ``client.admin.patches`` is: the roster is 19 competition-wide and carries **no bodies** — an event accumulates 20 report versions all week, so a listing that shipped them would read 21 every one out of SQLite to render a table of lengths. :meth:`get` 22 carries them, one team at a time, which is what retaining every 23 version was for. 24 """ 25 26 def __init__(self, http: BaseHttpClient) -> None: 27 self._http = http 28 29 def list(self, competition_id: str) -> ReportRosterResponse: 30 """Every current report's status in one competition. 31 32 One row per ``(team, range)``: a team that wrote up three of the 33 event's four ranges is three rows. 34 35 ``missing_team_count`` is served rather than derived: subtracting 36 the roster from the team list client-side is arithmetic two 37 clients can do differently, and then two organisers disagree 38 about who has not written anything. 39 """ 40 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/reports") 41 _raise_for_status(resp) 42 return ReportRosterResponse.model_validate(resp.json()) 43 44 def get(self, competition_id: str, team_id: str, challenge_id: str) -> ReportVersionsResponse: 45 """One team's whole report history on one range, bodies included. 46 47 ⚠️ The bodies are **attacker-authored text**. Render them as 48 text, never as HTML, and never feed one back into a prompt 49 outside the bounded matching task the grader defines. 50 """ 51 resp = self._http.request( 52 "GET", 53 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}", 54 ) 55 _raise_for_status(resp) 56 return ReportVersionsResponse.model_validate(resp.json()) 57 58 def attachments( 59 self, competition_id: str, team_id: str, challenge_id: str 60 ) -> ReportAttachmentsResponse: 61 """One team's evidence appendix for one range. 62 63 ⚠️ **Empty rather than 404** for a team that uploaded nothing, 64 unlike :meth:`get`. That one distinguishes "wrote nothing" from 65 "you typed the wrong team id"; by the time an organiser is here 66 the team has already been resolved by reading the report, and an 67 appendix with no files is the ordinary case. 68 """ 69 resp = self._http.request( 70 "GET", 71 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/attachments", 72 ) 73 _raise_for_status(resp) 74 return ReportAttachmentsResponse.model_validate(resp.json()) 75 76 def attachment_bytes( 77 self, competition_id: str, team_id: str, challenge_id: str, attachment_id: str 78 ) -> bytes: 79 """Fetch one team's attachment, as an organiser. 80 81 ⚠️ The bytes were chosen by an untrusted uploader. The platform 82 serves them ``nosniff`` + sandboxed and, for anything that is not 83 a real image, as a download — so treat a saved file the way you 84 would treat any artifact recovered from an engagement. 85 """ 86 resp = self._http.request( 87 "GET", 88 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}" 89 f"/attachments/{attachment_id}", 90 ) 91 _raise_for_status(resp) 92 return resp.content
Who has written a report, and what one team actually wrote.
Split for the same reason client.admin.patches is: the roster is
competition-wide and carries no bodies — an event accumulates
report versions all week, so a listing that shipped them would read
every one out of SQLite to render a table of lengths. get()
carries them, one team at a time, which is what retaining every
version was for.
29 def list(self, competition_id: str) -> ReportRosterResponse: 30 """Every current report's status in one competition. 31 32 One row per ``(team, range)``: a team that wrote up three of the 33 event's four ranges is three rows. 34 35 ``missing_team_count`` is served rather than derived: subtracting 36 the roster from the team list client-side is arithmetic two 37 clients can do differently, and then two organisers disagree 38 about who has not written anything. 39 """ 40 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/reports") 41 _raise_for_status(resp) 42 return ReportRosterResponse.model_validate(resp.json())
Every current report's status in one competition.
One row per (team, range): a team that wrote up three of the
event's four ranges is three rows.
missing_team_count is served rather than derived: subtracting
the roster from the team list client-side is arithmetic two
clients can do differently, and then two organisers disagree
about who has not written anything.
44 def get(self, competition_id: str, team_id: str, challenge_id: str) -> ReportVersionsResponse: 45 """One team's whole report history on one range, bodies included. 46 47 ⚠️ The bodies are **attacker-authored text**. Render them as 48 text, never as HTML, and never feed one back into a prompt 49 outside the bounded matching task the grader defines. 50 """ 51 resp = self._http.request( 52 "GET", 53 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}", 54 ) 55 _raise_for_status(resp) 56 return ReportVersionsResponse.model_validate(resp.json())
One team's whole report history on one range, bodies included.
⚠️ The bodies are attacker-authored text. Render them as text, never as HTML, and never feed one back into a prompt outside the bounded matching task the grader defines.
58 def attachments( 59 self, competition_id: str, team_id: str, challenge_id: str 60 ) -> ReportAttachmentsResponse: 61 """One team's evidence appendix for one range. 62 63 ⚠️ **Empty rather than 404** for a team that uploaded nothing, 64 unlike :meth:`get`. That one distinguishes "wrote nothing" from 65 "you typed the wrong team id"; by the time an organiser is here 66 the team has already been resolved by reading the report, and an 67 appendix with no files is the ordinary case. 68 """ 69 resp = self._http.request( 70 "GET", 71 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}/attachments", 72 ) 73 _raise_for_status(resp) 74 return ReportAttachmentsResponse.model_validate(resp.json())
One team's evidence appendix for one range.
⚠️ Empty rather than 404 for a team that uploaded nothing,
unlike get(). That one distinguishes "wrote nothing" from
"you typed the wrong team id"; by the time an organiser is here
the team has already been resolved by reading the report, and an
appendix with no files is the ordinary case.
76 def attachment_bytes( 77 self, competition_id: str, team_id: str, challenge_id: str, attachment_id: str 78 ) -> bytes: 79 """Fetch one team's attachment, as an organiser. 80 81 ⚠️ The bytes were chosen by an untrusted uploader. The platform 82 serves them ``nosniff`` + sandboxed and, for anything that is not 83 a real image, as a download — so treat a saved file the way you 84 would treat any artifact recovered from an engagement. 85 """ 86 resp = self._http.request( 87 "GET", 88 f"/admin/competitions/{competition_id}/reports/{team_id}/{challenge_id}" 89 f"/attachments/{attachment_id}", 90 ) 91 _raise_for_status(resp) 92 return resp.content
Fetch one team's attachment, as an organiser.
⚠️ The bytes were chosen by an untrusted uploader. The platform
serves them nosniff + sandboxed and, for anything that is not
a real image, as a download — so treat a saved file the way you
would treat any artifact recovered from an engagement.