ctfy.sdk.admin_resources.records

client.admin.records — archived instance forensics (admin).

  1"""``client.admin.records`` — archived instance forensics (admin)."""
  2
  3from __future__ import annotations
  4
  5import builtins
  6from typing import Any
  7
  8from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
  9from ctfy.sdk.base import BaseHttpClient
 10from ctfy.server.models import AnswerTraceResponse, InstanceRecordDetail, InstanceRecordInfo
 11
 12
 13class AdminRecordsResource:
 14    """Post-mortem forensics over terminal (archived) instance records."""
 15
 16    def __init__(self, http: BaseHttpClient) -> None:
 17        self._http = http
 18
 19    def list(
 20        self,
 21        *,
 22        team_id: str = "",
 23        challenge_id: str = "",
 24        node_id: str = "",
 25        status: str = "",
 26        since_ts: float | None = None,
 27        offset: int = 0,
 28        limit: int = 50,
 29    ) -> PagedList[InstanceRecordInfo]:
 30        """Archived (terminal) instance records — the post-mortem
 31        forensics index. ``since_ts`` is a UNIX epoch lower bound."""
 32        params: dict[str, Any] = {"offset": offset, "limit": limit}
 33        if team_id:
 34            params["team_id"] = team_id
 35        if challenge_id:
 36            params["challenge_id"] = challenge_id
 37        if node_id:
 38            params["node_id"] = node_id
 39        if status:
 40            params["status"] = status
 41        if since_ts is not None:
 42            params["since_ts"] = since_ts
 43        resp = self._http.request("GET", "/admin/instance-records", params=params)
 44        _raise_for_status(resp)
 45        return _extract_items(resp.json(), InstanceRecordInfo)
 46
 47    def get(self, instance_id: str) -> InstanceRecordDetail:
 48        """Full archived record for one instance (lifecycle, node,
 49        artifact availability flags)."""
 50        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}")
 51        _raise_for_status(resp)
 52        return InstanceRecordDetail.model_validate(resp.json())
 53
 54    def trace_answer(self, answer: str) -> AnswerTraceResponse:
 55        """Attribute a leaked answer to the team it was minted for.
 56
 57        The counterpart to per-team unique answers: when a flag turns up
 58        in a public channel, this says whose it was. Searches live
 59        instances (minted plaintext still in memory) and archived
 60        records (matched on the digest persisted at teardown).
 61
 62        *answer* is accepted wrapped (``FLAG{...}``) or bare — both
 63        normalise to the same digest, as submissions do. It travels in
 64        the request body, never a query string, so a live flag doesn't
 65        land in access logs on the way.
 66
 67        Check ``traceable_records`` on an empty result: instances
 68        archived before answer fingerprinting shipped carry no digest,
 69        so a miss against a mostly-untraceable archive means "unknown",
 70        not "not ours".
 71        """
 72        resp = self._http.request("POST", "/admin/answer-trace", json={"answer": answer})
 73        _raise_for_status(resp)
 74        return AnswerTraceResponse.model_validate(resp.json())
 75
 76    def container_log(self, instance_id: str) -> str:
 77        """Combined container stdout/stderr captured at teardown.
 78        Returns ``""`` when archival was disabled or nothing was kept."""
 79        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/container-log")
 80        _raise_for_status(resp)
 81        return str(resp.json().get("logs") or "")
 82
 83    def events(
 84        self, instance_id: str, offset: int = 0, limit: int = 50
 85    ) -> builtins.list[dict[str, Any]]:
 86        """Lifecycle event rows for one archived instance."""
 87        resp = self._http.request(
 88            "GET",
 89            f"/admin/instance-records/{instance_id}/events",
 90            params={"offset": offset, "limit": limit},
 91        )
 92        _raise_for_status(resp)
 93        items: builtins.list[dict[str, Any]] = resp.json()["items"]
 94        return items
 95
 96    def submissions(
 97        self, instance_id: str, offset: int = 0, limit: int = 50
 98    ) -> builtins.list[dict[str, Any]]:
 99        """Submission rows recorded against one archived instance."""
100        resp = self._http.request(
101            "GET",
102            f"/admin/instance-records/{instance_id}/submissions",
103            params={"offset": offset, "limit": limit},
104        )
105        _raise_for_status(resp)
106        items: builtins.list[dict[str, Any]] = resp.json()["items"]
107        return items
108
109    def traffic(
110        self, instance_id: str, offset: int = 0, limit: int = 50
111    ) -> builtins.list[dict[str, Any]]:
112        """Persisted mitmproxy flows for one archived instance."""
113        resp = self._http.request(
114            "GET",
115            f"/admin/instance-records/{instance_id}/traffic",
116            params={"offset": offset, "limit": limit},
117        )
118        _raise_for_status(resp)
119        items: builtins.list[dict[str, Any]] = resp.json()["items"]
120        return items
121
122    def pcap(self, instance_id: str) -> bytes:
123        """Archived tcpdump capture for one instance. Returns empty
124        ``bytes`` when no capture is on file."""
125        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/pcap")
126        if resp.status_code == 404:
127            return b""
128        _raise_for_status(resp)
129        return resp.content
class AdminRecordsResource:
 14class AdminRecordsResource:
 15    """Post-mortem forensics over terminal (archived) instance records."""
 16
 17    def __init__(self, http: BaseHttpClient) -> None:
 18        self._http = http
 19
 20    def list(
 21        self,
 22        *,
 23        team_id: str = "",
 24        challenge_id: str = "",
 25        node_id: str = "",
 26        status: str = "",
 27        since_ts: float | None = None,
 28        offset: int = 0,
 29        limit: int = 50,
 30    ) -> PagedList[InstanceRecordInfo]:
 31        """Archived (terminal) instance records — the post-mortem
 32        forensics index. ``since_ts`` is a UNIX epoch lower bound."""
 33        params: dict[str, Any] = {"offset": offset, "limit": limit}
 34        if team_id:
 35            params["team_id"] = team_id
 36        if challenge_id:
 37            params["challenge_id"] = challenge_id
 38        if node_id:
 39            params["node_id"] = node_id
 40        if status:
 41            params["status"] = status
 42        if since_ts is not None:
 43            params["since_ts"] = since_ts
 44        resp = self._http.request("GET", "/admin/instance-records", params=params)
 45        _raise_for_status(resp)
 46        return _extract_items(resp.json(), InstanceRecordInfo)
 47
 48    def get(self, instance_id: str) -> InstanceRecordDetail:
 49        """Full archived record for one instance (lifecycle, node,
 50        artifact availability flags)."""
 51        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}")
 52        _raise_for_status(resp)
 53        return InstanceRecordDetail.model_validate(resp.json())
 54
 55    def trace_answer(self, answer: str) -> AnswerTraceResponse:
 56        """Attribute a leaked answer to the team it was minted for.
 57
 58        The counterpart to per-team unique answers: when a flag turns up
 59        in a public channel, this says whose it was. Searches live
 60        instances (minted plaintext still in memory) and archived
 61        records (matched on the digest persisted at teardown).
 62
 63        *answer* is accepted wrapped (``FLAG{...}``) or bare — both
 64        normalise to the same digest, as submissions do. It travels in
 65        the request body, never a query string, so a live flag doesn't
 66        land in access logs on the way.
 67
 68        Check ``traceable_records`` on an empty result: instances
 69        archived before answer fingerprinting shipped carry no digest,
 70        so a miss against a mostly-untraceable archive means "unknown",
 71        not "not ours".
 72        """
 73        resp = self._http.request("POST", "/admin/answer-trace", json={"answer": answer})
 74        _raise_for_status(resp)
 75        return AnswerTraceResponse.model_validate(resp.json())
 76
 77    def container_log(self, instance_id: str) -> str:
 78        """Combined container stdout/stderr captured at teardown.
 79        Returns ``""`` when archival was disabled or nothing was kept."""
 80        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/container-log")
 81        _raise_for_status(resp)
 82        return str(resp.json().get("logs") or "")
 83
 84    def events(
 85        self, instance_id: str, offset: int = 0, limit: int = 50
 86    ) -> builtins.list[dict[str, Any]]:
 87        """Lifecycle event rows for one archived instance."""
 88        resp = self._http.request(
 89            "GET",
 90            f"/admin/instance-records/{instance_id}/events",
 91            params={"offset": offset, "limit": limit},
 92        )
 93        _raise_for_status(resp)
 94        items: builtins.list[dict[str, Any]] = resp.json()["items"]
 95        return items
 96
 97    def submissions(
 98        self, instance_id: str, offset: int = 0, limit: int = 50
 99    ) -> builtins.list[dict[str, Any]]:
100        """Submission rows recorded against one archived instance."""
101        resp = self._http.request(
102            "GET",
103            f"/admin/instance-records/{instance_id}/submissions",
104            params={"offset": offset, "limit": limit},
105        )
106        _raise_for_status(resp)
107        items: builtins.list[dict[str, Any]] = resp.json()["items"]
108        return items
109
110    def traffic(
111        self, instance_id: str, offset: int = 0, limit: int = 50
112    ) -> builtins.list[dict[str, Any]]:
113        """Persisted mitmproxy flows for one archived instance."""
114        resp = self._http.request(
115            "GET",
116            f"/admin/instance-records/{instance_id}/traffic",
117            params={"offset": offset, "limit": limit},
118        )
119        _raise_for_status(resp)
120        items: builtins.list[dict[str, Any]] = resp.json()["items"]
121        return items
122
123    def pcap(self, instance_id: str) -> bytes:
124        """Archived tcpdump capture for one instance. Returns empty
125        ``bytes`` when no capture is on file."""
126        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/pcap")
127        if resp.status_code == 404:
128            return b""
129        _raise_for_status(resp)
130        return resp.content

Post-mortem forensics over terminal (archived) instance records.

AdminRecordsResource(http: ctfy.sdk.base.BaseHttpClient)
17    def __init__(self, http: BaseHttpClient) -> None:
18        self._http = http
def list( self, *, team_id: str = '', challenge_id: str = '', node_id: str = '', status: str = '', since_ts: float | None = None, offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.InstanceRecordInfo]:
20    def list(
21        self,
22        *,
23        team_id: str = "",
24        challenge_id: str = "",
25        node_id: str = "",
26        status: str = "",
27        since_ts: float | None = None,
28        offset: int = 0,
29        limit: int = 50,
30    ) -> PagedList[InstanceRecordInfo]:
31        """Archived (terminal) instance records — the post-mortem
32        forensics index. ``since_ts`` is a UNIX epoch lower bound."""
33        params: dict[str, Any] = {"offset": offset, "limit": limit}
34        if team_id:
35            params["team_id"] = team_id
36        if challenge_id:
37            params["challenge_id"] = challenge_id
38        if node_id:
39            params["node_id"] = node_id
40        if status:
41            params["status"] = status
42        if since_ts is not None:
43            params["since_ts"] = since_ts
44        resp = self._http.request("GET", "/admin/instance-records", params=params)
45        _raise_for_status(resp)
46        return _extract_items(resp.json(), InstanceRecordInfo)

Archived (terminal) instance records — the post-mortem forensics index. since_ts is a UNIX epoch lower bound.

def get( self, instance_id: str) -> ctfy.server.models.InstanceRecordDetail:
48    def get(self, instance_id: str) -> InstanceRecordDetail:
49        """Full archived record for one instance (lifecycle, node,
50        artifact availability flags)."""
51        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}")
52        _raise_for_status(resp)
53        return InstanceRecordDetail.model_validate(resp.json())

Full archived record for one instance (lifecycle, node, artifact availability flags).

def trace_answer(self, answer: str) -> ctfy.server.models.AnswerTraceResponse:
55    def trace_answer(self, answer: str) -> AnswerTraceResponse:
56        """Attribute a leaked answer to the team it was minted for.
57
58        The counterpart to per-team unique answers: when a flag turns up
59        in a public channel, this says whose it was. Searches live
60        instances (minted plaintext still in memory) and archived
61        records (matched on the digest persisted at teardown).
62
63        *answer* is accepted wrapped (``FLAG{...}``) or bare — both
64        normalise to the same digest, as submissions do. It travels in
65        the request body, never a query string, so a live flag doesn't
66        land in access logs on the way.
67
68        Check ``traceable_records`` on an empty result: instances
69        archived before answer fingerprinting shipped carry no digest,
70        so a miss against a mostly-untraceable archive means "unknown",
71        not "not ours".
72        """
73        resp = self._http.request("POST", "/admin/answer-trace", json={"answer": answer})
74        _raise_for_status(resp)
75        return AnswerTraceResponse.model_validate(resp.json())

Attribute a leaked answer to the team it was minted for.

The counterpart to per-team unique answers: when a flag turns up in a public channel, this says whose it was. Searches live instances (minted plaintext still in memory) and archived records (matched on the digest persisted at teardown).

answer is accepted wrapped (FLAG{...}) or bare — both normalise to the same digest, as submissions do. It travels in the request body, never a query string, so a live flag doesn't land in access logs on the way.

Check traceable_records on an empty result: instances archived before answer fingerprinting shipped carry no digest, so a miss against a mostly-untraceable archive means "unknown", not "not ours".

def container_log(self, instance_id: str) -> str:
77    def container_log(self, instance_id: str) -> str:
78        """Combined container stdout/stderr captured at teardown.
79        Returns ``""`` when archival was disabled or nothing was kept."""
80        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/container-log")
81        _raise_for_status(resp)
82        return str(resp.json().get("logs") or "")

Combined container stdout/stderr captured at teardown. Returns "" when archival was disabled or nothing was kept.

def events( self, instance_id: str, offset: int = 0, limit: int = 50) -> list[dict[str, typing.Any]]:
84    def events(
85        self, instance_id: str, offset: int = 0, limit: int = 50
86    ) -> builtins.list[dict[str, Any]]:
87        """Lifecycle event rows for one archived instance."""
88        resp = self._http.request(
89            "GET",
90            f"/admin/instance-records/{instance_id}/events",
91            params={"offset": offset, "limit": limit},
92        )
93        _raise_for_status(resp)
94        items: builtins.list[dict[str, Any]] = resp.json()["items"]
95        return items

Lifecycle event rows for one archived instance.

def submissions( self, instance_id: str, offset: int = 0, limit: int = 50) -> list[dict[str, typing.Any]]:
 97    def submissions(
 98        self, instance_id: str, offset: int = 0, limit: int = 50
 99    ) -> builtins.list[dict[str, Any]]:
100        """Submission rows recorded against one archived instance."""
101        resp = self._http.request(
102            "GET",
103            f"/admin/instance-records/{instance_id}/submissions",
104            params={"offset": offset, "limit": limit},
105        )
106        _raise_for_status(resp)
107        items: builtins.list[dict[str, Any]] = resp.json()["items"]
108        return items

Submission rows recorded against one archived instance.

def traffic( self, instance_id: str, offset: int = 0, limit: int = 50) -> list[dict[str, typing.Any]]:
110    def traffic(
111        self, instance_id: str, offset: int = 0, limit: int = 50
112    ) -> builtins.list[dict[str, Any]]:
113        """Persisted mitmproxy flows for one archived instance."""
114        resp = self._http.request(
115            "GET",
116            f"/admin/instance-records/{instance_id}/traffic",
117            params={"offset": offset, "limit": limit},
118        )
119        _raise_for_status(resp)
120        items: builtins.list[dict[str, Any]] = resp.json()["items"]
121        return items

Persisted mitmproxy flows for one archived instance.

def pcap(self, instance_id: str) -> bytes:
123    def pcap(self, instance_id: str) -> bytes:
124        """Archived tcpdump capture for one instance. Returns empty
125        ``bytes`` when no capture is on file."""
126        resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/pcap")
127        if resp.status_code == 404:
128            return b""
129        _raise_for_status(resp)
130        return resp.content

Archived tcpdump capture for one instance. Returns empty bytes when no capture is on file.