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 ( 11 AnswerTraceResponse, 12 InstanceAnswersResponse, 13 InstanceRecordDetail, 14 InstanceRecordInfo, 15) 16 17 18class AdminRecordsResource: 19 """Post-mortem forensics over terminal (archived) instance records.""" 20 21 def __init__(self, http: BaseHttpClient) -> None: 22 self._http = http 23 24 def list( 25 self, 26 *, 27 team_id: str = "", 28 challenge_id: str = "", 29 node_id: str = "", 30 status: str = "", 31 since_ts: float | None = None, 32 offset: int = 0, 33 limit: int = 50, 34 ) -> PagedList[InstanceRecordInfo]: 35 """Archived (terminal) instance records — the post-mortem 36 forensics index. ``since_ts`` is a UNIX epoch lower bound.""" 37 params: dict[str, Any] = {"offset": offset, "limit": limit} 38 if team_id: 39 params["team_id"] = team_id 40 if challenge_id: 41 params["challenge_id"] = challenge_id 42 if node_id: 43 params["node_id"] = node_id 44 if status: 45 params["status"] = status 46 if since_ts is not None: 47 params["since_ts"] = since_ts 48 resp = self._http.request("GET", "/admin/instance-records", params=params) 49 _raise_for_status(resp) 50 return _extract_items(resp.json(), InstanceRecordInfo) 51 52 def get(self, instance_id: str) -> InstanceRecordDetail: 53 """Full archived record for one instance (lifecycle, node, 54 artifact availability flags).""" 55 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}") 56 _raise_for_status(resp) 57 return InstanceRecordDetail.model_validate(resp.json()) 58 59 def trace_answer(self, answer: str) -> AnswerTraceResponse: 60 """Attribute a leaked answer to the team it was minted for. 61 62 The counterpart to per-team unique answers: when a flag turns up 63 in a public channel, this says whose it was. Searches live 64 instances (minted plaintext still in memory) and archived 65 records (matched on the digest persisted at teardown). 66 67 *answer* is accepted wrapped (``FLAG{...}``) or bare — both 68 normalise to the same digest, as submissions do. It travels in 69 the request body, never a query string, so a live flag doesn't 70 land in access logs on the way. 71 72 Check ``traceable_records`` on an empty result: instances 73 archived before answer fingerprinting shipped carry no digest, 74 so a miss against a mostly-untraceable archive means "unknown", 75 not "not ours". 76 """ 77 resp = self._http.request("POST", "/admin/answer-trace", json={"answer": answer}) 78 _raise_for_status(resp) 79 return AnswerTraceResponse.model_validate(resp.json()) 80 81 def instance_answers(self, instance_id: str) -> InstanceAnswersResponse: 82 """The answers minted for one **live** instance. 83 84 ⚠️ The one call on this SDK that returns groundtruth. Everything 85 else goes the other way: the catalog redacts gated prompts, 86 :meth:`ChallengesResource.questions` widens *who may read the 87 question* and not *what is said*, and :meth:`answer_trace` takes 88 a value rather than handing one out. It exists because a dispute 89 about a submission otherwise has nothing to compare against. 90 91 Two consequences of that, both deliberate: 92 93 * **Every call is recorded.** The read emits an 94 ``instance_answers_revealed`` activity row naming the caller 95 and carrying the count — never the values. Do not put this in 96 a polling loop; each pass is a disclosure in the audit log. 97 * **Live instances only.** An archived record keeps a one-way 98 fingerprint of each answer and not the answer, so a stopped 99 instance raises rather than returning an empty list — 100 ``no answers here`` and ``this instance is gone`` are opposite 101 facts. Use :meth:`answer_trace` for an archived one. 102 103 Wraps ``GET /admin/instances/{id}/answers``.""" 104 resp = self._http.request("GET", f"/admin/instances/{instance_id}/answers") 105 _raise_for_status(resp) 106 return InstanceAnswersResponse.model_validate(resp.json()) 107 108 def container_log(self, instance_id: str) -> str: 109 """Combined container stdout/stderr captured at teardown. 110 Returns ``""`` when archival was disabled or nothing was kept.""" 111 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/container-log") 112 _raise_for_status(resp) 113 return str(resp.json().get("logs") or "") 114 115 def events( 116 self, instance_id: str, offset: int = 0, limit: int = 50 117 ) -> builtins.list[dict[str, Any]]: 118 """Lifecycle event rows for one archived instance.""" 119 resp = self._http.request( 120 "GET", 121 f"/admin/instance-records/{instance_id}/events", 122 params={"offset": offset, "limit": limit}, 123 ) 124 _raise_for_status(resp) 125 items: builtins.list[dict[str, Any]] = resp.json()["items"] 126 return items 127 128 def submissions( 129 self, instance_id: str, offset: int = 0, limit: int = 50 130 ) -> builtins.list[dict[str, Any]]: 131 """Submission rows recorded against one archived instance.""" 132 resp = self._http.request( 133 "GET", 134 f"/admin/instance-records/{instance_id}/submissions", 135 params={"offset": offset, "limit": limit}, 136 ) 137 _raise_for_status(resp) 138 items: builtins.list[dict[str, Any]] = resp.json()["items"] 139 return items 140 141 def traffic( 142 self, instance_id: str, offset: int = 0, limit: int = 50 143 ) -> builtins.list[dict[str, Any]]: 144 """Persisted mitmproxy flows for one archived instance.""" 145 resp = self._http.request( 146 "GET", 147 f"/admin/instance-records/{instance_id}/traffic", 148 params={"offset": offset, "limit": limit}, 149 ) 150 _raise_for_status(resp) 151 items: builtins.list[dict[str, Any]] = resp.json()["items"] 152 return items 153 154 def pcap(self, instance_id: str) -> bytes: 155 """Archived tcpdump capture for one instance. Returns empty 156 ``bytes`` when no capture is on file.""" 157 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/pcap") 158 if resp.status_code == 404: 159 return b"" 160 _raise_for_status(resp) 161 return resp.content
19class AdminRecordsResource: 20 """Post-mortem forensics over terminal (archived) instance records.""" 21 22 def __init__(self, http: BaseHttpClient) -> None: 23 self._http = http 24 25 def list( 26 self, 27 *, 28 team_id: str = "", 29 challenge_id: str = "", 30 node_id: str = "", 31 status: str = "", 32 since_ts: float | None = None, 33 offset: int = 0, 34 limit: int = 50, 35 ) -> PagedList[InstanceRecordInfo]: 36 """Archived (terminal) instance records — the post-mortem 37 forensics index. ``since_ts`` is a UNIX epoch lower bound.""" 38 params: dict[str, Any] = {"offset": offset, "limit": limit} 39 if team_id: 40 params["team_id"] = team_id 41 if challenge_id: 42 params["challenge_id"] = challenge_id 43 if node_id: 44 params["node_id"] = node_id 45 if status: 46 params["status"] = status 47 if since_ts is not None: 48 params["since_ts"] = since_ts 49 resp = self._http.request("GET", "/admin/instance-records", params=params) 50 _raise_for_status(resp) 51 return _extract_items(resp.json(), InstanceRecordInfo) 52 53 def get(self, instance_id: str) -> InstanceRecordDetail: 54 """Full archived record for one instance (lifecycle, node, 55 artifact availability flags).""" 56 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}") 57 _raise_for_status(resp) 58 return InstanceRecordDetail.model_validate(resp.json()) 59 60 def trace_answer(self, answer: str) -> AnswerTraceResponse: 61 """Attribute a leaked answer to the team it was minted for. 62 63 The counterpart to per-team unique answers: when a flag turns up 64 in a public channel, this says whose it was. Searches live 65 instances (minted plaintext still in memory) and archived 66 records (matched on the digest persisted at teardown). 67 68 *answer* is accepted wrapped (``FLAG{...}``) or bare — both 69 normalise to the same digest, as submissions do. It travels in 70 the request body, never a query string, so a live flag doesn't 71 land in access logs on the way. 72 73 Check ``traceable_records`` on an empty result: instances 74 archived before answer fingerprinting shipped carry no digest, 75 so a miss against a mostly-untraceable archive means "unknown", 76 not "not ours". 77 """ 78 resp = self._http.request("POST", "/admin/answer-trace", json={"answer": answer}) 79 _raise_for_status(resp) 80 return AnswerTraceResponse.model_validate(resp.json()) 81 82 def instance_answers(self, instance_id: str) -> InstanceAnswersResponse: 83 """The answers minted for one **live** instance. 84 85 ⚠️ The one call on this SDK that returns groundtruth. Everything 86 else goes the other way: the catalog redacts gated prompts, 87 :meth:`ChallengesResource.questions` widens *who may read the 88 question* and not *what is said*, and :meth:`answer_trace` takes 89 a value rather than handing one out. It exists because a dispute 90 about a submission otherwise has nothing to compare against. 91 92 Two consequences of that, both deliberate: 93 94 * **Every call is recorded.** The read emits an 95 ``instance_answers_revealed`` activity row naming the caller 96 and carrying the count — never the values. Do not put this in 97 a polling loop; each pass is a disclosure in the audit log. 98 * **Live instances only.** An archived record keeps a one-way 99 fingerprint of each answer and not the answer, so a stopped 100 instance raises rather than returning an empty list — 101 ``no answers here`` and ``this instance is gone`` are opposite 102 facts. Use :meth:`answer_trace` for an archived one. 103 104 Wraps ``GET /admin/instances/{id}/answers``.""" 105 resp = self._http.request("GET", f"/admin/instances/{instance_id}/answers") 106 _raise_for_status(resp) 107 return InstanceAnswersResponse.model_validate(resp.json()) 108 109 def container_log(self, instance_id: str) -> str: 110 """Combined container stdout/stderr captured at teardown. 111 Returns ``""`` when archival was disabled or nothing was kept.""" 112 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/container-log") 113 _raise_for_status(resp) 114 return str(resp.json().get("logs") or "") 115 116 def events( 117 self, instance_id: str, offset: int = 0, limit: int = 50 118 ) -> builtins.list[dict[str, Any]]: 119 """Lifecycle event rows for one archived instance.""" 120 resp = self._http.request( 121 "GET", 122 f"/admin/instance-records/{instance_id}/events", 123 params={"offset": offset, "limit": limit}, 124 ) 125 _raise_for_status(resp) 126 items: builtins.list[dict[str, Any]] = resp.json()["items"] 127 return items 128 129 def submissions( 130 self, instance_id: str, offset: int = 0, limit: int = 50 131 ) -> builtins.list[dict[str, Any]]: 132 """Submission rows recorded against one archived instance.""" 133 resp = self._http.request( 134 "GET", 135 f"/admin/instance-records/{instance_id}/submissions", 136 params={"offset": offset, "limit": limit}, 137 ) 138 _raise_for_status(resp) 139 items: builtins.list[dict[str, Any]] = resp.json()["items"] 140 return items 141 142 def traffic( 143 self, instance_id: str, offset: int = 0, limit: int = 50 144 ) -> builtins.list[dict[str, Any]]: 145 """Persisted mitmproxy flows for one archived instance.""" 146 resp = self._http.request( 147 "GET", 148 f"/admin/instance-records/{instance_id}/traffic", 149 params={"offset": offset, "limit": limit}, 150 ) 151 _raise_for_status(resp) 152 items: builtins.list[dict[str, Any]] = resp.json()["items"] 153 return items 154 155 def pcap(self, instance_id: str) -> bytes: 156 """Archived tcpdump capture for one instance. Returns empty 157 ``bytes`` when no capture is on file.""" 158 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/pcap") 159 if resp.status_code == 404: 160 return b"" 161 _raise_for_status(resp) 162 return resp.content
Post-mortem forensics over terminal (archived) instance records.
25 def list( 26 self, 27 *, 28 team_id: str = "", 29 challenge_id: str = "", 30 node_id: str = "", 31 status: str = "", 32 since_ts: float | None = None, 33 offset: int = 0, 34 limit: int = 50, 35 ) -> PagedList[InstanceRecordInfo]: 36 """Archived (terminal) instance records — the post-mortem 37 forensics index. ``since_ts`` is a UNIX epoch lower bound.""" 38 params: dict[str, Any] = {"offset": offset, "limit": limit} 39 if team_id: 40 params["team_id"] = team_id 41 if challenge_id: 42 params["challenge_id"] = challenge_id 43 if node_id: 44 params["node_id"] = node_id 45 if status: 46 params["status"] = status 47 if since_ts is not None: 48 params["since_ts"] = since_ts 49 resp = self._http.request("GET", "/admin/instance-records", params=params) 50 _raise_for_status(resp) 51 return _extract_items(resp.json(), InstanceRecordInfo)
Archived (terminal) instance records — the post-mortem
forensics index. since_ts is a UNIX epoch lower bound.
53 def get(self, instance_id: str) -> InstanceRecordDetail: 54 """Full archived record for one instance (lifecycle, node, 55 artifact availability flags).""" 56 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}") 57 _raise_for_status(resp) 58 return InstanceRecordDetail.model_validate(resp.json())
Full archived record for one instance (lifecycle, node, artifact availability flags).
60 def trace_answer(self, answer: str) -> AnswerTraceResponse: 61 """Attribute a leaked answer to the team it was minted for. 62 63 The counterpart to per-team unique answers: when a flag turns up 64 in a public channel, this says whose it was. Searches live 65 instances (minted plaintext still in memory) and archived 66 records (matched on the digest persisted at teardown). 67 68 *answer* is accepted wrapped (``FLAG{...}``) or bare — both 69 normalise to the same digest, as submissions do. It travels in 70 the request body, never a query string, so a live flag doesn't 71 land in access logs on the way. 72 73 Check ``traceable_records`` on an empty result: instances 74 archived before answer fingerprinting shipped carry no digest, 75 so a miss against a mostly-untraceable archive means "unknown", 76 not "not ours". 77 """ 78 resp = self._http.request("POST", "/admin/answer-trace", json={"answer": answer}) 79 _raise_for_status(resp) 80 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".
82 def instance_answers(self, instance_id: str) -> InstanceAnswersResponse: 83 """The answers minted for one **live** instance. 84 85 ⚠️ The one call on this SDK that returns groundtruth. Everything 86 else goes the other way: the catalog redacts gated prompts, 87 :meth:`ChallengesResource.questions` widens *who may read the 88 question* and not *what is said*, and :meth:`answer_trace` takes 89 a value rather than handing one out. It exists because a dispute 90 about a submission otherwise has nothing to compare against. 91 92 Two consequences of that, both deliberate: 93 94 * **Every call is recorded.** The read emits an 95 ``instance_answers_revealed`` activity row naming the caller 96 and carrying the count — never the values. Do not put this in 97 a polling loop; each pass is a disclosure in the audit log. 98 * **Live instances only.** An archived record keeps a one-way 99 fingerprint of each answer and not the answer, so a stopped 100 instance raises rather than returning an empty list — 101 ``no answers here`` and ``this instance is gone`` are opposite 102 facts. Use :meth:`answer_trace` for an archived one. 103 104 Wraps ``GET /admin/instances/{id}/answers``.""" 105 resp = self._http.request("GET", f"/admin/instances/{instance_id}/answers") 106 _raise_for_status(resp) 107 return InstanceAnswersResponse.model_validate(resp.json())
The answers minted for one live instance.
⚠️ The one call on this SDK that returns groundtruth. Everything
else goes the other way: the catalog redacts gated prompts,
ChallengesResource.questions() widens who may read the
question and not what is said, and answer_trace() takes
a value rather than handing one out. It exists because a dispute
about a submission otherwise has nothing to compare against.
Two consequences of that, both deliberate:
- Every call is recorded. The read emits an
instance_answers_revealedactivity row naming the caller and carrying the count — never the values. Do not put this in a polling loop; each pass is a disclosure in the audit log. - Live instances only. An archived record keeps a one-way
fingerprint of each answer and not the answer, so a stopped
instance raises rather than returning an empty list —
no answers hereandthis instance is goneare opposite facts. Useanswer_trace()for an archived one.
Wraps GET /admin/instances/{id}/answers.
109 def container_log(self, instance_id: str) -> str: 110 """Combined container stdout/stderr captured at teardown. 111 Returns ``""`` when archival was disabled or nothing was kept.""" 112 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/container-log") 113 _raise_for_status(resp) 114 return str(resp.json().get("logs") or "")
Combined container stdout/stderr captured at teardown.
Returns "" when archival was disabled or nothing was kept.
116 def events( 117 self, instance_id: str, offset: int = 0, limit: int = 50 118 ) -> builtins.list[dict[str, Any]]: 119 """Lifecycle event rows for one archived instance.""" 120 resp = self._http.request( 121 "GET", 122 f"/admin/instance-records/{instance_id}/events", 123 params={"offset": offset, "limit": limit}, 124 ) 125 _raise_for_status(resp) 126 items: builtins.list[dict[str, Any]] = resp.json()["items"] 127 return items
Lifecycle event rows for one archived instance.
129 def submissions( 130 self, instance_id: str, offset: int = 0, limit: int = 50 131 ) -> builtins.list[dict[str, Any]]: 132 """Submission rows recorded against one archived instance.""" 133 resp = self._http.request( 134 "GET", 135 f"/admin/instance-records/{instance_id}/submissions", 136 params={"offset": offset, "limit": limit}, 137 ) 138 _raise_for_status(resp) 139 items: builtins.list[dict[str, Any]] = resp.json()["items"] 140 return items
Submission rows recorded against one archived instance.
142 def traffic( 143 self, instance_id: str, offset: int = 0, limit: int = 50 144 ) -> builtins.list[dict[str, Any]]: 145 """Persisted mitmproxy flows for one archived instance.""" 146 resp = self._http.request( 147 "GET", 148 f"/admin/instance-records/{instance_id}/traffic", 149 params={"offset": offset, "limit": limit}, 150 ) 151 _raise_for_status(resp) 152 items: builtins.list[dict[str, Any]] = resp.json()["items"] 153 return items
Persisted mitmproxy flows for one archived instance.
155 def pcap(self, instance_id: str) -> bytes: 156 """Archived tcpdump capture for one instance. Returns empty 157 ``bytes`` when no capture is on file.""" 158 resp = self._http.request("GET", f"/admin/instance-records/{instance_id}/pcap") 159 if resp.status_code == 404: 160 return b"" 161 _raise_for_status(resp) 162 return resp.content
Archived tcpdump capture for one instance. Returns empty
bytes when no capture is on file.