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())
class ReportsResource:
 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.

ReportsResource(http: ctfy.sdk.base.BaseHttpClient)
32    def __init__(self, http: BaseHttpClient) -> None:
33        self._http = http
def save( self, competition_id: str, challenge_id: str, content: str) -> ctfy.server.models.ReportSaveResponse:
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.

def mine(self, competition_id: str) -> ctfy.server.models.MyReportsResponse:
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.

def get( self, competition_id: str, challenge_id: str) -> ctfy.server.models.ReportInfo:
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.

def versions( self, competition_id: str, challenge_id: str) -> ctfy.server.models.ReportVersionsResponse:
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.

def attachments( self, competition_id: str, challenge_id: str) -> ctfy.server.models.ReportAttachmentsResponse:
 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.

def upload_attachment( self, competition_id: str, challenge_id: str, *, content: bytes, filename: str = 'evidence.png', content_type: str = 'application/octet-stream') -> ctfy.server.models.ReportAttachmentInfo:
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.

def attachment_bytes( self, competition_id: str, challenge_id: str, attachment_id: str) -> bytes:
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.

def delete_attachment( self, competition_id: str, challenge_id: str, attachment_id: str) -> ctfy.server.models.ReportAttachmentsResponse:
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.