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
class AdminReportsResource:
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.

AdminReportsResource(http: ctfy.sdk.base.BaseHttpClient)
26    def __init__(self, http: BaseHttpClient) -> None:
27        self._http = http
def list( self, competition_id: str) -> ctfy.server.models.ReportRosterResponse:
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.

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

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

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