ctfy.sdk.resources.reports
client.reports — the engagement report a team is graded on.
1"""``client.reports`` — the engagement report a team is graded on.""" 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 MyReportsResponse, 9 ReportAttachmentInfo, 10 ReportAttachmentsResponse, 11 ReportInfo, 12 ReportSaveResponse, 13 ReportVersionsResponse, 14) 15 16 17class ReportsResource: 18 """One revisable report per (team, competition, challenge). 19 20 A GAUNTLET range is one estate with its own kill chain, so an event 21 curating four of them is four engagements and four write-ups. The 22 competition still owns the deadline; the artifact is per range. 23 24 ⚠️ **The last version saved is the one that gets graded**, and every 25 version is kept for the appeal. So :meth:`save` sends the whole 26 document each time — there is no append — and an empty one is 27 refused rather than stored, because an accidental blank save would 28 otherwise replace the artifact the team is scored on. 29 """ 30 31 def __init__(self, http: BaseHttpClient) -> None: 32 self._http = http 33 34 def save(self, competition_id: str, challenge_id: str, content: str) -> ReportSaveResponse: 35 """Save a new version; get back what it wrote and what it credited. 36 37 Refused outside the competition's play sessions and for a 38 disqualified team, exactly like every other progress path — the 39 report *is* this format's progress path. 40 41 ``newly_credited`` counts the sentinels quoted in **this** save 42 that the team did not already hold, which is the whole feedback 43 channel the format allows: enough to learn the last edit was 44 worth something, never enough to learn how many exist. 45 """ 46 resp = self._http.request( 47 "PUT", 48 f"/competitions/{competition_id}/challenges/{challenge_id}/report", 49 json={"content": content}, 50 ) 51 _raise_for_status(resp) 52 return ReportSaveResponse.model_validate(resp.json()) 53 54 def mine(self, competition_id: str) -> MyReportsResponse: 55 """Which ranges this team has written up, and how much of each. 56 57 Only ranges with a saved report — the ones not started are the 58 competition's challenge list minus these, which the caller 59 already holds. 60 """ 61 resp = self._http.request("GET", f"/competitions/{competition_id}/reports") 62 _raise_for_status(resp) 63 return MyReportsResponse.model_validate(resp.json()) 64 65 def get(self, competition_id: str, challenge_id: str) -> ReportInfo: 66 """This team's current report on one range — the highest version. 67 68 **404 when nothing has been saved**, rather than an empty 69 report: a blank body would be indistinguishable from a saved 70 empty one, and the write path refuses those precisely so the two 71 remain different facts. 72 """ 73 resp = self._http.request( 74 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report" 75 ) 76 _raise_for_status(resp) 77 return ReportInfo.model_validate(resp.json()) 78 79 def versions(self, competition_id: str, challenge_id: str) -> ReportVersionsResponse: 80 """Every version saved for one range, newest first, with bodies.""" 81 resp = self._http.request( 82 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/versions" 83 ) 84 _raise_for_status(resp) 85 return ReportVersionsResponse.model_validate(resp.json()) 86 87 # -- the appendix ------------------------------------------------------ 88 # 89 # ⚠️ **Attachments are evidence, never the answer.** Grading reads 90 # the report *text* — the sentinel extraction is a regex over it and 91 # the narrative grader is handed its prose — so a flag legible only 92 # inside a screenshot scores nothing. Upload the screenshot *and* 93 # write the value and the finding out in the report. 94 95 def attachments(self, competition_id: str, challenge_id: str) -> ReportAttachmentsResponse: 96 """This engagement's appendix, oldest first, plus how many more it may take.""" 97 resp = self._http.request( 98 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments" 99 ) 100 _raise_for_status(resp) 101 return ReportAttachmentsResponse.model_validate(resp.json()) 102 103 def upload_attachment( 104 self, 105 competition_id: str, 106 challenge_id: str, 107 *, 108 content: bytes, 109 filename: str = "evidence.png", 110 content_type: str = "application/octet-stream", 111 ) -> ReportAttachmentInfo: 112 """Add one evidence file to this engagement's appendix. 113 114 The server sniffs the bytes, so ``filename`` and ``content_type`` 115 fill the multipart envelope and decide nothing: real image bytes 116 get an image type and may render inline for the organiser, 117 anything else is stored opaque and served as a download. 118 119 Refused when the deployment has no object storage configured 120 (``503 storage_unavailable``) — the report itself still saves, 121 it just takes text only. 122 """ 123 resp = self._http.request( 124 "POST", 125 f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments", 126 files={"file": (filename, content, content_type)}, 127 ) 128 _raise_for_status(resp) 129 return ReportAttachmentInfo.model_validate(resp.json()) 130 131 def attachment_bytes(self, competition_id: str, challenge_id: str, attachment_id: str) -> bytes: 132 """Fetch one of this team's own attachments. 133 134 Streamed through the platform rather than from a bucket URL, so 135 the object store needs no public-read policy. 136 """ 137 resp = self._http.request( 138 "GET", 139 f"/competitions/{competition_id}/challenges/{challenge_id}" 140 f"/report/attachments/{attachment_id}", 141 ) 142 _raise_for_status(resp) 143 return resp.content 144 145 def delete_attachment( 146 self, competition_id: str, challenge_id: str, attachment_id: str 147 ) -> ReportAttachmentsResponse: 148 """Remove one attachment. Returns the appendix as it now stands. 149 150 A hard delete: the reason a team removes a file is that it should 151 not have been uploaded, and keeping the bytes would answer that 152 request with a lie. 153 """ 154 resp = self._http.request( 155 "DELETE", 156 f"/competitions/{competition_id}/challenges/{challenge_id}" 157 f"/report/attachments/{attachment_id}", 158 ) 159 _raise_for_status(resp) 160 return ReportAttachmentsResponse.model_validate(resp.json())
18class ReportsResource: 19 """One revisable report per (team, competition, challenge). 20 21 A GAUNTLET range is one estate with its own kill chain, so an event 22 curating four of them is four engagements and four write-ups. The 23 competition still owns the deadline; the artifact is per range. 24 25 ⚠️ **The last version saved is the one that gets graded**, and every 26 version is kept for the appeal. So :meth:`save` sends the whole 27 document each time — there is no append — and an empty one is 28 refused rather than stored, because an accidental blank save would 29 otherwise replace the artifact the team is scored on. 30 """ 31 32 def __init__(self, http: BaseHttpClient) -> None: 33 self._http = http 34 35 def save(self, competition_id: str, challenge_id: str, content: str) -> ReportSaveResponse: 36 """Save a new version; get back what it wrote and what it credited. 37 38 Refused outside the competition's play sessions and for a 39 disqualified team, exactly like every other progress path — the 40 report *is* this format's progress path. 41 42 ``newly_credited`` counts the sentinels quoted in **this** save 43 that the team did not already hold, which is the whole feedback 44 channel the format allows: enough to learn the last edit was 45 worth something, never enough to learn how many exist. 46 """ 47 resp = self._http.request( 48 "PUT", 49 f"/competitions/{competition_id}/challenges/{challenge_id}/report", 50 json={"content": content}, 51 ) 52 _raise_for_status(resp) 53 return ReportSaveResponse.model_validate(resp.json()) 54 55 def mine(self, competition_id: str) -> MyReportsResponse: 56 """Which ranges this team has written up, and how much of each. 57 58 Only ranges with a saved report — the ones not started are the 59 competition's challenge list minus these, which the caller 60 already holds. 61 """ 62 resp = self._http.request("GET", f"/competitions/{competition_id}/reports") 63 _raise_for_status(resp) 64 return MyReportsResponse.model_validate(resp.json()) 65 66 def get(self, competition_id: str, challenge_id: str) -> ReportInfo: 67 """This team's current report on one range — the highest version. 68 69 **404 when nothing has been saved**, rather than an empty 70 report: a blank body would be indistinguishable from a saved 71 empty one, and the write path refuses those precisely so the two 72 remain different facts. 73 """ 74 resp = self._http.request( 75 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report" 76 ) 77 _raise_for_status(resp) 78 return ReportInfo.model_validate(resp.json()) 79 80 def versions(self, competition_id: str, challenge_id: str) -> ReportVersionsResponse: 81 """Every version saved for one range, newest first, with bodies.""" 82 resp = self._http.request( 83 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/versions" 84 ) 85 _raise_for_status(resp) 86 return ReportVersionsResponse.model_validate(resp.json()) 87 88 # -- the appendix ------------------------------------------------------ 89 # 90 # ⚠️ **Attachments are evidence, never the answer.** Grading reads 91 # the report *text* — the sentinel extraction is a regex over it and 92 # the narrative grader is handed its prose — so a flag legible only 93 # inside a screenshot scores nothing. Upload the screenshot *and* 94 # write the value and the finding out in the report. 95 96 def attachments(self, competition_id: str, challenge_id: str) -> ReportAttachmentsResponse: 97 """This engagement's appendix, oldest first, plus how many more it may take.""" 98 resp = self._http.request( 99 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments" 100 ) 101 _raise_for_status(resp) 102 return ReportAttachmentsResponse.model_validate(resp.json()) 103 104 def upload_attachment( 105 self, 106 competition_id: str, 107 challenge_id: str, 108 *, 109 content: bytes, 110 filename: str = "evidence.png", 111 content_type: str = "application/octet-stream", 112 ) -> ReportAttachmentInfo: 113 """Add one evidence file to this engagement's appendix. 114 115 The server sniffs the bytes, so ``filename`` and ``content_type`` 116 fill the multipart envelope and decide nothing: real image bytes 117 get an image type and may render inline for the organiser, 118 anything else is stored opaque and served as a download. 119 120 Refused when the deployment has no object storage configured 121 (``503 storage_unavailable``) — the report itself still saves, 122 it just takes text only. 123 """ 124 resp = self._http.request( 125 "POST", 126 f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments", 127 files={"file": (filename, content, content_type)}, 128 ) 129 _raise_for_status(resp) 130 return ReportAttachmentInfo.model_validate(resp.json()) 131 132 def attachment_bytes(self, competition_id: str, challenge_id: str, attachment_id: str) -> bytes: 133 """Fetch one of this team's own attachments. 134 135 Streamed through the platform rather than from a bucket URL, so 136 the object store needs no public-read policy. 137 """ 138 resp = self._http.request( 139 "GET", 140 f"/competitions/{competition_id}/challenges/{challenge_id}" 141 f"/report/attachments/{attachment_id}", 142 ) 143 _raise_for_status(resp) 144 return resp.content 145 146 def delete_attachment( 147 self, competition_id: str, challenge_id: str, attachment_id: str 148 ) -> ReportAttachmentsResponse: 149 """Remove one attachment. Returns the appendix as it now stands. 150 151 A hard delete: the reason a team removes a file is that it should 152 not have been uploaded, and keeping the bytes would answer that 153 request with a lie. 154 """ 155 resp = self._http.request( 156 "DELETE", 157 f"/competitions/{competition_id}/challenges/{challenge_id}" 158 f"/report/attachments/{attachment_id}", 159 ) 160 _raise_for_status(resp) 161 return ReportAttachmentsResponse.model_validate(resp.json())
One revisable report per (team, competition, challenge).
A GAUNTLET range is one estate with its own kill chain, so an event curating four of them is four engagements and four write-ups. The competition still owns the deadline; the artifact is per range.
⚠️ The last version saved is the one that gets graded, and every
version is kept for the appeal. So save() sends the whole
document each time — there is no append — and an empty one is
refused rather than stored, because an accidental blank save would
otherwise replace the artifact the team is scored on.
35 def save(self, competition_id: str, challenge_id: str, content: str) -> ReportSaveResponse: 36 """Save a new version; get back what it wrote and what it credited. 37 38 Refused outside the competition's play sessions and for a 39 disqualified team, exactly like every other progress path — the 40 report *is* this format's progress path. 41 42 ``newly_credited`` counts the sentinels quoted in **this** save 43 that the team did not already hold, which is the whole feedback 44 channel the format allows: enough to learn the last edit was 45 worth something, never enough to learn how many exist. 46 """ 47 resp = self._http.request( 48 "PUT", 49 f"/competitions/{competition_id}/challenges/{challenge_id}/report", 50 json={"content": content}, 51 ) 52 _raise_for_status(resp) 53 return ReportSaveResponse.model_validate(resp.json())
Save a new version; get back what it wrote and what it credited.
Refused outside the competition's play sessions and for a disqualified team, exactly like every other progress path — the report is this format's progress path.
newly_credited counts the sentinels quoted in this save
that the team did not already hold, which is the whole feedback
channel the format allows: enough to learn the last edit was
worth something, never enough to learn how many exist.
55 def mine(self, competition_id: str) -> MyReportsResponse: 56 """Which ranges this team has written up, and how much of each. 57 58 Only ranges with a saved report — the ones not started are the 59 competition's challenge list minus these, which the caller 60 already holds. 61 """ 62 resp = self._http.request("GET", f"/competitions/{competition_id}/reports") 63 _raise_for_status(resp) 64 return MyReportsResponse.model_validate(resp.json())
Which ranges this team has written up, and how much of each.
Only ranges with a saved report — the ones not started are the competition's challenge list minus these, which the caller already holds.
66 def get(self, competition_id: str, challenge_id: str) -> ReportInfo: 67 """This team's current report on one range — the highest version. 68 69 **404 when nothing has been saved**, rather than an empty 70 report: a blank body would be indistinguishable from a saved 71 empty one, and the write path refuses those precisely so the two 72 remain different facts. 73 """ 74 resp = self._http.request( 75 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report" 76 ) 77 _raise_for_status(resp) 78 return ReportInfo.model_validate(resp.json())
This team's current report on one range — the highest version.
404 when nothing has been saved, rather than an empty report: a blank body would be indistinguishable from a saved empty one, and the write path refuses those precisely so the two remain different facts.
80 def versions(self, competition_id: str, challenge_id: str) -> ReportVersionsResponse: 81 """Every version saved for one range, newest first, with bodies.""" 82 resp = self._http.request( 83 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/versions" 84 ) 85 _raise_for_status(resp) 86 return ReportVersionsResponse.model_validate(resp.json())
Every version saved for one range, newest first, with bodies.
96 def attachments(self, competition_id: str, challenge_id: str) -> ReportAttachmentsResponse: 97 """This engagement's appendix, oldest first, plus how many more it may take.""" 98 resp = self._http.request( 99 "GET", f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments" 100 ) 101 _raise_for_status(resp) 102 return ReportAttachmentsResponse.model_validate(resp.json())
This engagement's appendix, oldest first, plus how many more it may take.
104 def upload_attachment( 105 self, 106 competition_id: str, 107 challenge_id: str, 108 *, 109 content: bytes, 110 filename: str = "evidence.png", 111 content_type: str = "application/octet-stream", 112 ) -> ReportAttachmentInfo: 113 """Add one evidence file to this engagement's appendix. 114 115 The server sniffs the bytes, so ``filename`` and ``content_type`` 116 fill the multipart envelope and decide nothing: real image bytes 117 get an image type and may render inline for the organiser, 118 anything else is stored opaque and served as a download. 119 120 Refused when the deployment has no object storage configured 121 (``503 storage_unavailable``) — the report itself still saves, 122 it just takes text only. 123 """ 124 resp = self._http.request( 125 "POST", 126 f"/competitions/{competition_id}/challenges/{challenge_id}/report/attachments", 127 files={"file": (filename, content, content_type)}, 128 ) 129 _raise_for_status(resp) 130 return ReportAttachmentInfo.model_validate(resp.json())
Add one evidence file to this engagement's appendix.
The server sniffs the bytes, so filename and content_type
fill the multipart envelope and decide nothing: real image bytes
get an image type and may render inline for the organiser,
anything else is stored opaque and served as a download.
Refused when the deployment has no object storage configured
(503 storage_unavailable) — the report itself still saves,
it just takes text only.
132 def attachment_bytes(self, competition_id: str, challenge_id: str, attachment_id: str) -> bytes: 133 """Fetch one of this team's own attachments. 134 135 Streamed through the platform rather than from a bucket URL, so 136 the object store needs no public-read policy. 137 """ 138 resp = self._http.request( 139 "GET", 140 f"/competitions/{competition_id}/challenges/{challenge_id}" 141 f"/report/attachments/{attachment_id}", 142 ) 143 _raise_for_status(resp) 144 return resp.content
Fetch one of this team's own attachments.
Streamed through the platform rather than from a bucket URL, so the object store needs no public-read policy.
146 def delete_attachment( 147 self, competition_id: str, challenge_id: str, attachment_id: str 148 ) -> ReportAttachmentsResponse: 149 """Remove one attachment. Returns the appendix as it now stands. 150 151 A hard delete: the reason a team removes a file is that it should 152 not have been uploaded, and keeping the bytes would answer that 153 request with a lie. 154 """ 155 resp = self._http.request( 156 "DELETE", 157 f"/competitions/{competition_id}/challenges/{challenge_id}" 158 f"/report/attachments/{attachment_id}", 159 ) 160 _raise_for_status(resp) 161 return ReportAttachmentsResponse.model_validate(resp.json())
Remove one attachment. Returns the appendix as it now stands.
A hard delete: the reason a team removes a file is that it should not have been uploaded, and keeping the bytes would answer that request with a lie.