@getmagical/cli 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +408 -17
- package/dist/index.js +11905 -2783
- package/dist/index.js.map +4 -4
- package/dist/network-recording-archive.py +519 -0
- package/package.json +4 -1
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bounded offline reader for Magical mitmproxy recording archives."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import zlib
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, BinaryIO
|
|
15
|
+
|
|
16
|
+
EXPECTED_MITMPROXY_VERSION = "12.2.3"
|
|
17
|
+
MAX_REQUEST_BYTES = 64 * 1024
|
|
18
|
+
READ_CHUNK_BYTES = 64 * 1024
|
|
19
|
+
EXCHANGE_REFERENCE_PATTERN = re.compile(
|
|
20
|
+
r"^mitm-v1:([0-9a-f]{64}):([0-9]+)$"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ArchiveError(Exception):
|
|
25
|
+
def __init__(self, code: str, message: str):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.code = code
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BoundedFlowFile:
|
|
31
|
+
"""Limit individual parser reads before tnetstring allocates a flow record."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, archive: BinaryIO, max_read_bytes: int):
|
|
34
|
+
self.archive = archive
|
|
35
|
+
self.max_read_bytes = max_read_bytes
|
|
36
|
+
|
|
37
|
+
def read(self, size: int = -1) -> bytes:
|
|
38
|
+
if size < 0:
|
|
39
|
+
raise ArchiveError(
|
|
40
|
+
"UNSUPPORTED_ARCHIVE_FORMAT",
|
|
41
|
+
"Expected a mitmproxy flow archive, not a JSON archive.",
|
|
42
|
+
)
|
|
43
|
+
if size > self.max_read_bytes:
|
|
44
|
+
raise ArchiveError(
|
|
45
|
+
"FLOW_TOO_LARGE", "A serialized archive flow exceeds the size limit."
|
|
46
|
+
)
|
|
47
|
+
return self.archive.read(size)
|
|
48
|
+
|
|
49
|
+
def peek(self, size: int = 0) -> bytes:
|
|
50
|
+
if size > self.max_read_bytes:
|
|
51
|
+
raise ArchiveError(
|
|
52
|
+
"FLOW_TOO_LARGE", "A serialized archive flow exceeds the size limit."
|
|
53
|
+
)
|
|
54
|
+
peek = getattr(self.archive, "peek", None)
|
|
55
|
+
return peek(size) if peek is not None else b""
|
|
56
|
+
|
|
57
|
+
def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
|
|
58
|
+
return self.archive.seek(offset, whence)
|
|
59
|
+
|
|
60
|
+
def tell(self) -> int:
|
|
61
|
+
return self.archive.tell()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def emit(value: dict[str, Any]) -> None:
|
|
65
|
+
json.dump(value, sys.stdout, separators=(",", ":"))
|
|
66
|
+
sys.stdout.write("\n")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fail(code: str, message: str) -> None:
|
|
70
|
+
emit({"ok": False, "error": {"code": code, "message": message}})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_request() -> dict[str, Any]:
|
|
74
|
+
request_bytes = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1)
|
|
75
|
+
if len(request_bytes) > MAX_REQUEST_BYTES:
|
|
76
|
+
raise ArchiveError("INVALID_REQUEST", "Parser request is too large.")
|
|
77
|
+
try:
|
|
78
|
+
request = json.loads(request_bytes)
|
|
79
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
80
|
+
raise ArchiveError("INVALID_REQUEST", "Parser request is not valid JSON.") from error
|
|
81
|
+
if not isinstance(request, dict):
|
|
82
|
+
raise ArchiveError("INVALID_REQUEST", "Parser request must be an object.")
|
|
83
|
+
return request
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def require_string(request: dict[str, Any], key: str) -> str:
|
|
87
|
+
value = request.get(key)
|
|
88
|
+
if not isinstance(value, str) or not value:
|
|
89
|
+
raise ArchiveError("INVALID_REQUEST", f"{key} must be a non-empty string.")
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def require_positive_integer(request: dict[str, Any], key: str) -> int:
|
|
94
|
+
value = request.get(key)
|
|
95
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
96
|
+
raise ArchiveError("INVALID_REQUEST", f"{key} must be a positive integer.")
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def require_positive_number(request: dict[str, Any], key: str) -> float:
|
|
101
|
+
value = request.get(key)
|
|
102
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
103
|
+
raise ArchiveError("INVALID_REQUEST", f"{key} must be a positive number.")
|
|
104
|
+
return float(value)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def archive_identity(archive_path: str, max_archive_bytes: int) -> tuple[int, str]:
|
|
108
|
+
try:
|
|
109
|
+
size_bytes = os.path.getsize(archive_path)
|
|
110
|
+
except OSError as error:
|
|
111
|
+
raise ArchiveError("ARCHIVE_UNAVAILABLE", "Recording archive is unavailable.") from error
|
|
112
|
+
if size_bytes > max_archive_bytes:
|
|
113
|
+
raise ArchiveError("ARCHIVE_TOO_LARGE", "Recording archive exceeds the size limit.")
|
|
114
|
+
|
|
115
|
+
digest = hashlib.sha256()
|
|
116
|
+
try:
|
|
117
|
+
with open(archive_path, "rb") as archive:
|
|
118
|
+
while chunk := archive.read(1024 * 1024):
|
|
119
|
+
digest.update(chunk)
|
|
120
|
+
except OSError as error:
|
|
121
|
+
raise ArchiveError("ARCHIVE_UNAVAILABLE", "Recording archive could not be read.") from error
|
|
122
|
+
return size_bytes, digest.hexdigest()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def projected_origin(flow: Any) -> str:
|
|
126
|
+
request = flow.request
|
|
127
|
+
try:
|
|
128
|
+
split = urllib.parse.urlsplit(request.url)
|
|
129
|
+
scheme = split.scheme.lower()
|
|
130
|
+
hostname = split.hostname or request.host
|
|
131
|
+
port = split.port
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
scheme = str(request.scheme).lower()
|
|
134
|
+
hostname = str(request.host)
|
|
135
|
+
port = request.port
|
|
136
|
+
default_port = (scheme == "http" and port == 80) or (
|
|
137
|
+
scheme == "https" and port == 443
|
|
138
|
+
)
|
|
139
|
+
authority_host = f"[{hostname}]" if ":" in hostname else hostname
|
|
140
|
+
host = (
|
|
141
|
+
authority_host
|
|
142
|
+
if port is None or default_port
|
|
143
|
+
else f"{authority_host}:{port}"
|
|
144
|
+
)
|
|
145
|
+
origin = f"{scheme}://{host}"
|
|
146
|
+
if len(origin) > 512 or any(character < " " for character in origin):
|
|
147
|
+
raise ArchiveError("UNSUPPORTED_EXTRACTION", "Request origin is invalid.")
|
|
148
|
+
return origin
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def normalized_media_type(value: str | None) -> str | None:
|
|
152
|
+
if value is None:
|
|
153
|
+
return None
|
|
154
|
+
if any(character < " " for character in value):
|
|
155
|
+
return None
|
|
156
|
+
media_type = value.partition(";")[0].strip().lower()
|
|
157
|
+
token = r"[!#$%&'*+.^_`|~0-9a-z-]+"
|
|
158
|
+
return media_type if re.fullmatch(rf"{token}/{token}", media_type) else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def request_timestamp(flow: Any) -> str | None:
|
|
162
|
+
try:
|
|
163
|
+
return datetime.fromtimestamp(
|
|
164
|
+
flow.request.timestamp_start, timezone.utc
|
|
165
|
+
).isoformat().replace("+00:00", "Z")
|
|
166
|
+
except (OverflowError, OSError, TypeError, ValueError):
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def body_metadata(response: Any) -> dict[str, Any]:
|
|
171
|
+
raw_content = response.raw_content
|
|
172
|
+
if raw_content is None:
|
|
173
|
+
return {"state": "MISSING", "encodedSizeBytes": None}
|
|
174
|
+
if not raw_content:
|
|
175
|
+
return {"state": "EMPTY", "encodedSizeBytes": 0}
|
|
176
|
+
return {"state": "PRESENT", "encodedSizeBytes": len(raw_content)}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def response_metadata(response: Any) -> dict[str, Any] | None:
|
|
180
|
+
if response is None:
|
|
181
|
+
return None
|
|
182
|
+
return {
|
|
183
|
+
"status": response.status_code,
|
|
184
|
+
"contentType": normalized_media_type(response.headers.get("content-type")),
|
|
185
|
+
"body": body_metadata(response),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def read_next_flow(iterator: Any) -> Any | None:
|
|
190
|
+
try:
|
|
191
|
+
return next(iterator)
|
|
192
|
+
except StopIteration:
|
|
193
|
+
return None
|
|
194
|
+
except Exception as error:
|
|
195
|
+
from mitmproxy import exceptions
|
|
196
|
+
|
|
197
|
+
if isinstance(error, exceptions.FlowReadException):
|
|
198
|
+
raise ArchiveError("MALFORMED_ARCHIVE", "Recording archive is malformed.") from error
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def inspect_archive(
|
|
203
|
+
request: dict[str, Any], archive_path: str, size_bytes: int, archive_sha256: str
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
from mitmproxy import http, io
|
|
206
|
+
|
|
207
|
+
max_flows = require_positive_integer(request, "maxFlows")
|
|
208
|
+
max_exchanges = require_positive_integer(request, "maxExchanges")
|
|
209
|
+
max_serialized_flow_bytes = require_positive_integer(
|
|
210
|
+
request, "maxSerializedFlowBytes"
|
|
211
|
+
)
|
|
212
|
+
exchanges: list[dict[str, Any]] = []
|
|
213
|
+
scanned_flow_count = 0
|
|
214
|
+
complete = False
|
|
215
|
+
truncation_reason: str | None = None
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
with open(archive_path, "rb") as archive:
|
|
219
|
+
bounded_archive = BoundedFlowFile(archive, max_serialized_flow_bytes)
|
|
220
|
+
iterator = iter(io.FlowReader(bounded_archive).stream())
|
|
221
|
+
while scanned_flow_count < max_flows:
|
|
222
|
+
flow = read_next_flow(iterator)
|
|
223
|
+
if flow is None:
|
|
224
|
+
complete = True
|
|
225
|
+
break
|
|
226
|
+
flow_index = scanned_flow_count
|
|
227
|
+
scanned_flow_count += 1
|
|
228
|
+
if not isinstance(flow, http.HTTPFlow) or flow.request is None:
|
|
229
|
+
continue
|
|
230
|
+
if len(exchanges) >= max_exchanges:
|
|
231
|
+
truncation_reason = "RESULT_LIMIT"
|
|
232
|
+
break
|
|
233
|
+
method = flow.request.method
|
|
234
|
+
if len(method) > 32 or not re.fullmatch(
|
|
235
|
+
r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", method
|
|
236
|
+
):
|
|
237
|
+
method = "UNKNOWN"
|
|
238
|
+
exchanges.append(
|
|
239
|
+
{
|
|
240
|
+
"exchange": f"mitm-v1:{archive_sha256}:{flow_index}",
|
|
241
|
+
"index": flow_index,
|
|
242
|
+
"method": method,
|
|
243
|
+
"origin": projected_origin(flow),
|
|
244
|
+
"timestamp": request_timestamp(flow),
|
|
245
|
+
"response": response_metadata(flow.response),
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
if archive.peek(1):
|
|
250
|
+
truncation_reason = "FLOW_LIMIT"
|
|
251
|
+
else:
|
|
252
|
+
complete = True
|
|
253
|
+
except ArchiveError:
|
|
254
|
+
raise
|
|
255
|
+
except OSError as error:
|
|
256
|
+
raise ArchiveError("ARCHIVE_UNAVAILABLE", "Recording archive could not be read.") from error
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
"archive": {"sha256": archive_sha256, "sizeBytes": size_bytes},
|
|
260
|
+
"exchanges": exchanges,
|
|
261
|
+
"scan": {
|
|
262
|
+
"scannedFlowCount": scanned_flow_count,
|
|
263
|
+
"returnedExchangeCount": len(exchanges),
|
|
264
|
+
"complete": complete,
|
|
265
|
+
"resultLimit": max_exchanges,
|
|
266
|
+
"flowLimit": max_flows,
|
|
267
|
+
"truncationReason": truncation_reason,
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def decode_zlib(content: bytes, max_output_bytes: int, window_bits: int) -> bytes:
|
|
273
|
+
decoder = zlib.decompressobj(window_bits)
|
|
274
|
+
output = bytearray()
|
|
275
|
+
for offset in range(0, len(content), READ_CHUNK_BYTES):
|
|
276
|
+
pending = content[offset : offset + READ_CHUNK_BYTES]
|
|
277
|
+
while pending:
|
|
278
|
+
remaining = max_output_bytes - len(output)
|
|
279
|
+
decoded = decoder.decompress(pending, remaining + 1)
|
|
280
|
+
output.extend(decoded)
|
|
281
|
+
if len(output) > max_output_bytes:
|
|
282
|
+
raise ArchiveError(
|
|
283
|
+
"DECODED_BODY_TOO_LARGE",
|
|
284
|
+
"Decoded response body exceeds the size limit.",
|
|
285
|
+
)
|
|
286
|
+
pending = decoder.unconsumed_tail
|
|
287
|
+
output.extend(decoder.flush(max_output_bytes - len(output) + 1))
|
|
288
|
+
if len(output) > max_output_bytes:
|
|
289
|
+
raise ArchiveError(
|
|
290
|
+
"DECODED_BODY_TOO_LARGE", "Decoded response body exceeds the size limit."
|
|
291
|
+
)
|
|
292
|
+
if not decoder.eof or decoder.unused_data:
|
|
293
|
+
raise zlib.error("incomplete stream or trailing data")
|
|
294
|
+
return bytes(output)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def decode_one(content: bytes, encoding: str, max_output_bytes: int) -> bytes:
|
|
298
|
+
if encoding in {"", "identity"}:
|
|
299
|
+
if len(content) > max_output_bytes:
|
|
300
|
+
raise ArchiveError(
|
|
301
|
+
"DECODED_BODY_TOO_LARGE", "Decoded response body exceeds the size limit."
|
|
302
|
+
)
|
|
303
|
+
return content
|
|
304
|
+
try:
|
|
305
|
+
if encoding == "gzip":
|
|
306
|
+
return decode_zlib(content, max_output_bytes, 31)
|
|
307
|
+
if encoding == "deflate":
|
|
308
|
+
try:
|
|
309
|
+
return decode_zlib(content, max_output_bytes, zlib.MAX_WBITS)
|
|
310
|
+
except zlib.error:
|
|
311
|
+
return decode_zlib(content, max_output_bytes, -zlib.MAX_WBITS)
|
|
312
|
+
except ArchiveError:
|
|
313
|
+
raise
|
|
314
|
+
except Exception as error:
|
|
315
|
+
raise ArchiveError(
|
|
316
|
+
"INVALID_CONTENT_ENCODING", "Response content encoding is invalid."
|
|
317
|
+
) from error
|
|
318
|
+
raise ArchiveError(
|
|
319
|
+
"UNSUPPORTED_ENCODING",
|
|
320
|
+
f"Response content encoding {encoding!r} is unsupported.",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def decode_content(
|
|
325
|
+
content: bytes,
|
|
326
|
+
content_encoding: str | None,
|
|
327
|
+
max_decoded_body_bytes: int,
|
|
328
|
+
max_expansion_ratio: float,
|
|
329
|
+
) -> bytes:
|
|
330
|
+
expansion_limit = int(max(len(content), 1) * max_expansion_ratio)
|
|
331
|
+
output_limit = min(max_decoded_body_bytes, expansion_limit)
|
|
332
|
+
encodings = [
|
|
333
|
+
encoding.strip().lower()
|
|
334
|
+
for encoding in (content_encoding or "identity").split(",")
|
|
335
|
+
]
|
|
336
|
+
if len(encodings) != 1:
|
|
337
|
+
raise ArchiveError(
|
|
338
|
+
"UNSUPPORTED_ENCODING",
|
|
339
|
+
"Stacked response content encodings are unsupported.",
|
|
340
|
+
)
|
|
341
|
+
decoded = content
|
|
342
|
+
try:
|
|
343
|
+
for encoding in reversed(encodings):
|
|
344
|
+
decoded = decode_one(decoded, encoding, output_limit)
|
|
345
|
+
except ArchiveError as error:
|
|
346
|
+
if (
|
|
347
|
+
error.code == "DECODED_BODY_TOO_LARGE"
|
|
348
|
+
and expansion_limit < max_decoded_body_bytes
|
|
349
|
+
):
|
|
350
|
+
raise ArchiveError(
|
|
351
|
+
"DECOMPRESSION_RATIO_EXCEEDED",
|
|
352
|
+
"Decoded response body exceeds the decompression ratio limit.",
|
|
353
|
+
) from error
|
|
354
|
+
raise
|
|
355
|
+
if len(decoded) > max_decoded_body_bytes:
|
|
356
|
+
raise ArchiveError(
|
|
357
|
+
"DECODED_BODY_TOO_LARGE", "Decoded response body exceeds the size limit."
|
|
358
|
+
)
|
|
359
|
+
if len(decoded) > max(len(content), 1) * max_expansion_ratio:
|
|
360
|
+
raise ArchiveError(
|
|
361
|
+
"DECOMPRESSION_RATIO_EXCEEDED",
|
|
362
|
+
"Decoded response body exceeds the decompression ratio limit.",
|
|
363
|
+
)
|
|
364
|
+
return decoded
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def write_private_file(output_path: str, content: bytes) -> None:
|
|
368
|
+
descriptor: int | None = None
|
|
369
|
+
try:
|
|
370
|
+
descriptor = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
371
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
372
|
+
descriptor = None
|
|
373
|
+
output.write(content)
|
|
374
|
+
output.flush()
|
|
375
|
+
os.fsync(output.fileno())
|
|
376
|
+
except FileExistsError as error:
|
|
377
|
+
raise ArchiveError("OUTPUT_EXISTS", "Response output already exists.") from error
|
|
378
|
+
except OSError as error:
|
|
379
|
+
try:
|
|
380
|
+
os.unlink(output_path)
|
|
381
|
+
except OSError:
|
|
382
|
+
pass
|
|
383
|
+
raise ArchiveError("OUTPUT_WRITE_FAILED", "Response output could not be written.") from error
|
|
384
|
+
finally:
|
|
385
|
+
if descriptor is not None:
|
|
386
|
+
os.close(descriptor)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def extract_response(
|
|
390
|
+
request: dict[str, Any], archive_path: str, size_bytes: int, archive_sha256: str
|
|
391
|
+
) -> dict[str, Any]:
|
|
392
|
+
from mitmproxy import http, io
|
|
393
|
+
|
|
394
|
+
reference = require_string(request, "exchange")
|
|
395
|
+
match = EXCHANGE_REFERENCE_PATTERN.fullmatch(reference)
|
|
396
|
+
if match is None:
|
|
397
|
+
raise ArchiveError("INVALID_EXCHANGE_REFERENCE", "Exchange reference is invalid.")
|
|
398
|
+
expected_sha256, index_text = match.groups()
|
|
399
|
+
if expected_sha256 != archive_sha256:
|
|
400
|
+
raise ArchiveError(
|
|
401
|
+
"ARCHIVE_MISMATCH", "Exchange reference belongs to a different archive."
|
|
402
|
+
)
|
|
403
|
+
target_index = int(index_text)
|
|
404
|
+
max_flows = require_positive_integer(request, "maxFlows")
|
|
405
|
+
max_serialized_flow_bytes = require_positive_integer(
|
|
406
|
+
request, "maxSerializedFlowBytes"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
with open(archive_path, "rb") as archive:
|
|
411
|
+
bounded_archive = BoundedFlowFile(archive, max_serialized_flow_bytes)
|
|
412
|
+
iterator = iter(io.FlowReader(bounded_archive).stream())
|
|
413
|
+
flow = None
|
|
414
|
+
current_index = 0
|
|
415
|
+
while current_index < max_flows:
|
|
416
|
+
current_flow = read_next_flow(iterator)
|
|
417
|
+
if current_flow is None:
|
|
418
|
+
break
|
|
419
|
+
if current_index == target_index:
|
|
420
|
+
flow = current_flow
|
|
421
|
+
current_index += 1
|
|
422
|
+
if current_index == max_flows and archive.peek(1):
|
|
423
|
+
raise ArchiveError(
|
|
424
|
+
"FLOW_LIMIT_EXCEEDED", "Recording archive exceeds the flow limit."
|
|
425
|
+
)
|
|
426
|
+
except ArchiveError:
|
|
427
|
+
raise
|
|
428
|
+
except OSError as error:
|
|
429
|
+
raise ArchiveError("ARCHIVE_UNAVAILABLE", "Recording archive could not be read.") from error
|
|
430
|
+
|
|
431
|
+
if flow is None:
|
|
432
|
+
raise ArchiveError("EXCHANGE_NOT_FOUND", "Exchange was not found.")
|
|
433
|
+
if not isinstance(flow, http.HTTPFlow) or flow.request is None:
|
|
434
|
+
raise ArchiveError("UNSUPPORTED_FLOW", "Exchange is not an HTTP request.")
|
|
435
|
+
if flow.response is None:
|
|
436
|
+
raise ArchiveError("MISSING_RESPONSE", "Exchange has no recorded response.")
|
|
437
|
+
if flow.response.raw_content is None:
|
|
438
|
+
raise ArchiveError("MISSING_BODY", "Exchange response body was not recorded.")
|
|
439
|
+
|
|
440
|
+
encoded_content = flow.response.raw_content
|
|
441
|
+
max_encoded_body_bytes = require_positive_integer(request, "maxEncodedBodyBytes")
|
|
442
|
+
if len(encoded_content) > max_encoded_body_bytes:
|
|
443
|
+
raise ArchiveError(
|
|
444
|
+
"ENCODED_BODY_TOO_LARGE", "Encoded response body exceeds the size limit."
|
|
445
|
+
)
|
|
446
|
+
decoded_content = decode_content(
|
|
447
|
+
encoded_content,
|
|
448
|
+
flow.response.headers.get("content-encoding"),
|
|
449
|
+
require_positive_integer(request, "maxDecodedBodyBytes"),
|
|
450
|
+
require_positive_number(request, "maxExpansionRatio"),
|
|
451
|
+
)
|
|
452
|
+
output_path = require_string(request, "outputPath")
|
|
453
|
+
write_private_file(output_path, decoded_content)
|
|
454
|
+
content_encoding = flow.response.headers.get("content-encoding")
|
|
455
|
+
normalized_encodings = [
|
|
456
|
+
encoding.strip().lower()
|
|
457
|
+
for encoding in (content_encoding or "").split(",")
|
|
458
|
+
if encoding.strip()
|
|
459
|
+
]
|
|
460
|
+
return {
|
|
461
|
+
"archive": {"sha256": archive_sha256, "sizeBytes": size_bytes},
|
|
462
|
+
"exchange": reference,
|
|
463
|
+
"index": target_index,
|
|
464
|
+
"response": {
|
|
465
|
+
"contentType": normalized_media_type(
|
|
466
|
+
flow.response.headers.get("content-type")
|
|
467
|
+
),
|
|
468
|
+
"sourceContentEncoding": ", ".join(normalized_encodings) or None,
|
|
469
|
+
"representation": "decoded-entity-body",
|
|
470
|
+
"encodedSizeBytes": len(encoded_content),
|
|
471
|
+
"sizeBytes": len(decoded_content),
|
|
472
|
+
"sha256": hashlib.sha256(decoded_content).hexdigest(),
|
|
473
|
+
},
|
|
474
|
+
"outputPath": output_path,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def main() -> int:
|
|
479
|
+
try:
|
|
480
|
+
if sys.version_info < (3, 12):
|
|
481
|
+
raise ArchiveError(
|
|
482
|
+
"RUNTIME_VERSION_MISMATCH", "Python 3.12 or newer is required."
|
|
483
|
+
)
|
|
484
|
+
request = read_request()
|
|
485
|
+
try:
|
|
486
|
+
from mitmproxy import version
|
|
487
|
+
except ImportError as error:
|
|
488
|
+
raise ArchiveError(
|
|
489
|
+
"RUNTIME_UNAVAILABLE",
|
|
490
|
+
"Python environment does not contain mitmproxy 12.2.3.",
|
|
491
|
+
) from error
|
|
492
|
+
if version.VERSION != EXPECTED_MITMPROXY_VERSION:
|
|
493
|
+
raise ArchiveError(
|
|
494
|
+
"RUNTIME_VERSION_MISMATCH",
|
|
495
|
+
f"Expected mitmproxy {EXPECTED_MITMPROXY_VERSION}.",
|
|
496
|
+
)
|
|
497
|
+
archive_path = require_string(request, "archivePath")
|
|
498
|
+
size_bytes, archive_sha256 = archive_identity(
|
|
499
|
+
archive_path, require_positive_integer(request, "maxArchiveBytes")
|
|
500
|
+
)
|
|
501
|
+
operation = require_string(request, "operation")
|
|
502
|
+
if operation == "inspect":
|
|
503
|
+
result = inspect_archive(request, archive_path, size_bytes, archive_sha256)
|
|
504
|
+
elif operation == "extract":
|
|
505
|
+
result = extract_response(request, archive_path, size_bytes, archive_sha256)
|
|
506
|
+
else:
|
|
507
|
+
raise ArchiveError("INVALID_REQUEST", "Parser operation is invalid.")
|
|
508
|
+
emit({"ok": True, "result": result})
|
|
509
|
+
return 0
|
|
510
|
+
except ArchiveError as error:
|
|
511
|
+
fail(error.code, str(error))
|
|
512
|
+
return 1
|
|
513
|
+
except Exception:
|
|
514
|
+
fail("PARSER_FAILED", "Recording archive parser failed.")
|
|
515
|
+
return 1
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
if __name__ == "__main__":
|
|
519
|
+
raise SystemExit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getmagical/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Magical CLI for shared MCP operations",
|
|
@@ -23,13 +23,16 @@
|
|
|
23
23
|
"magical": "./bin/mgcl.mjs"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
+
"@inquirer/prompts": "^7.10.1",
|
|
26
27
|
"commander": "^14.0.3",
|
|
27
28
|
"keytar": "^7.9.0",
|
|
29
|
+
"mime-types": "^2.1.35",
|
|
28
30
|
"zod": "^4.1.12"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
31
33
|
"@magical/common": "0.0.0",
|
|
32
34
|
"@magical/mcp-registry": "0.0.0",
|
|
35
|
+
"@types/mime-types": "^2.1.4",
|
|
33
36
|
"esbuild": "^0.28.0",
|
|
34
37
|
"vitest": "^4.1.7"
|
|
35
38
|
},
|