Skip to content

Commit eeddca8

Browse files
committed
apiv2: add download endpoints for pcap variants, TLS keys, ETW logs, bulk archives
CAPE supports several TLS-interception pipelines that each produce different on-disk artifacts — PolarProxy writes polarproxy/tls.pcap and its processor mergecaps it into dump.pcap; SSLproxy writes a synthetic sslproxy/sslproxy.pcap plus an NSS keylog; the tlsdump / sslkeylogfile hooks produce in-guest keylogs that decryptpcap feeds through GoGoRoboCap to yield dump_decrypted.pcap and dump_mixed.pcap. The web UI file-download view already surfaces all of these, but the REST API only served dump.pcap plus a legacy tasks_tlspcap endpoint hard-coded to polarproxy/tls.pcap — fine for PolarProxy operators, 404s for everyone else. The new ETW / AMSI telemetry and the three in-guest keylogs have no download path at all. This brings apiv2 to parity with the web UI and wires up the newer artifacts. Endpoints are parameterised rather than split per-artifact so operators see four route shapes instead of sixteen: tasks/get/pcap/<id>/ dump.pcap (existing, unchanged) tasks/get/pcap/<id>/<variant>/ variant ∈ {decrypted, mixed, sslproxy, zip, pcapng} tasks/get/keys/<id>/<kind>/ kind ∈ {tls, ssl, master} — NSS-format keylogs from the three hook sources (tls: MockSSL; ssl: bcrypt+ncrypt; master: SSLproxy) tasks/get/etw/<id>/<kind>/ kind ∈ {dns, network, wmi} NDJSON streams; kind == amsi zips the AMSI script buffers tasks/get/bulkzip/<id>/<folder>/ folder ∈ {logs, network, memory, selfextracted} — AES-zipped with ZIP_PWD for parity with tasks_dropped / tasks_payloadfiles / tasks_procdumpfiles tasks/get/tlspcap/<id>/ existing endpoint; now prefers dump_decrypted.pcap and falls back to polarproxy/tls.pcap, so both TLS pipelines serve from the same URL Three new apiconf sections gate the sensitive / bulk categories separately: [tasktlskeys] TLS key material (decrypts captured flows) [tasketw] ETW JSON logs [taskbulkzip] whole-directory archives PCAP variants reuse [taskpcap] since operators who opted into pcap access already implicitly trust the caller with packet-capture data. create_zip gains a recursive os.walk (replacing os.listdir) with relative-path preservation, so bulk archives of nested directories — notably logs/filestore/<bucket>/* — now include their contents instead of silently dropping everything below the top level. A new temp_file=True option routes the archive through a disk-backed NamedTemporaryFile so large folders stream without loading the full archive into RAM; the bulkzip handler uses this mode. The pcapng variant generates into a per-request NamedTemporaryFile and unlinks it as soon as the fd is handed to FileWrapper. Writing the pcapng to a shared path inside the analysis dir raced: two concurrent callers could stream each other truncated or partially-overwritten output. Variant / kind / folder inputs are matched against a static whitelist before any path is built, so the URL parameter can't be used to probe paths outside the analysis dir. Implementation uses shared helpers — _resolve_task_id, _serve_analysis_file, _zip_paths, _serve_folder_zip, _pcapng_response, _pcapzip_response — so each of the four new handlers reduces to a small dispatch table.
1 parent 976b369 commit eeddca8

4 files changed

Lines changed: 299 additions & 24 deletions

File tree

conf/default/api.conf.default

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,36 @@ rps = 1/s
244244
#rpm = 10/m
245245
mcp = no
246246

247+
# Pull TLS keylog material (tlsdump.log / sslkeys.log / master_keys.log).
248+
# These are sensitive — they decrypt captured TLS flows — so gated separately
249+
# from other downloads even if operator has enabled [taskpcap].
250+
[tasktlskeys]
251+
enabled = yes
252+
auth_only = no
253+
rps = 1/s
254+
#rpm = 10/m
255+
mcp = no
256+
257+
# Pull ETW JSON logs (dns_etw.json, network_etw.json, wmi_etw.json, amsi_etw/).
258+
# Companion to [taskevtx] — these cover the ETW-sourced signals used for
259+
# process→network attribution and script-content inspection.
260+
[tasketw]
261+
enabled = yes
262+
auth_only = no
263+
rps = 1/s
264+
#rpm = 10/m
265+
mcp = no
266+
267+
# Bulk directory zip endpoints — logs/, network/, memory/, selfextracted/.
268+
# These can be large and expose a lot of artifacts at once; disable if you
269+
# don't want operators pulling entire analysis trees over the API.
270+
[taskbulkzip]
271+
enabled = yes
272+
auth_only = no
273+
rps = 1/s
274+
#rpm = 10/m
275+
mcp = no
276+
247277
# Pull the dropped files from a specific task
248278
[taskdropped]
249279
enabled = yes

lib/cuckoo/common/utils.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,27 +135,35 @@ def is_text_file(file_info, destination_folder, buf, file_data=False):
135135
return file_data.decode("latin-1")
136136

137137

138-
def create_zip(files=False, folder=False, encrypted=False):
138+
def create_zip(files=False, folder=False, encrypted=False, temp_file=False):
139139
"""Utility function to create zip archive with file(s)
140140
@param files: file or list of files
141141
@param folder: path to folder to compress
142142
@param encrypted: create password protected and AES encrypted file
143+
@param temp_file: if True, returns a tempfile.NamedTemporaryFile instead of BytesIO
143144
"""
144145

145146
if folder:
146147
# To avoid when we have only folder argument
147148
if not files:
148149
files = []
149-
files += [os.path.join(folder, file) for file in os.listdir(folder)]
150+
for root, _, fnames in os.walk(folder):
151+
for fname in fnames:
152+
files.append(os.path.join(root, fname))
150153

151154
if not isinstance(files, list):
152155
files = [files]
153156

154-
mem_zip = BytesIO()
157+
if temp_file:
158+
mem_zip = tempfile.NamedTemporaryFile(delete=True)
159+
else:
160+
mem_zip = BytesIO()
161+
155162
if encrypted and HAVE_PYZIPPER:
156163
zipper = pyzipper.AESZipFile(mem_zip, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES)
157164
else:
158-
zipper = zipfile.ZipFile(mem_zip, "a", zipfile.ZIP_DEFLATED, False)
165+
zipper = zipfile.ZipFile(mem_zip, "w" if temp_file else "a", zipfile.ZIP_DEFLATED, False)
166+
159167
with zipper as zf:
160168
if encrypted:
161169
zf.setpassword(zippwd)
@@ -164,8 +172,14 @@ def create_zip(files=False, folder=False, encrypted=False):
164172
log.error("File does't exist: %s", file)
165173
continue
166174

167-
parent_folder = os.path.dirname(file).rsplit(os.sep, 1)[-1]
168-
path = os.path.join(parent_folder, os.path.basename(file))
175+
if folder and file.startswith(folder):
176+
rel_path = os.path.relpath(file, folder)
177+
folder_basename = os.path.basename(os.path.normpath(folder))
178+
path = os.path.join(folder_basename, rel_path)
179+
else:
180+
parent_folder = os.path.dirname(file).rsplit(os.sep, 1)[-1]
181+
path = os.path.join(parent_folder, os.path.basename(file))
182+
169183
zf.write(file, path)
170184

171185
mem_zip.seek(0)

web/apiv2/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@
4848
re_path(r"^tasks/get/procmemory/(?P<task_id>\d+)/(?P<pid>\d{1,5})/$", views.tasks_procmemory),
4949
re_path(r"^tasks/get/fullmemory/(?P<task_id>\d+)/$", views.tasks_fullmemory),
5050
re_path(r"^tasks/get/pcap/(?P<task_id>\d+)/$", views.tasks_pcap),
51+
re_path(r"^tasks/get/pcap/(?P<task_id>\d+)/(?P<variant>\w+)/$", views.tasks_pcap_variant),
5152
re_path(r"^tasks/get/tlspcap/(?P<task_id>\d+)/$", views.tasks_tlspcap),
53+
re_path(r"^tasks/get/keys/(?P<task_id>\d+)/(?P<kind>\w+)/$", views.tasks_keys),
54+
re_path(r"^tasks/get/etw/(?P<task_id>\d+)/(?P<kind>\w+)/$", views.tasks_etw),
55+
re_path(r"^tasks/get/bulkzip/(?P<task_id>\d+)/(?P<folder>\w+)/$", views.tasks_bulkzip),
5256
re_path(r"^tasks/get/evtx/(?P<task_id>\d+)/$", views.tasks_evtx),
5357
re_path(r"^tasks/get/dropped/(?P<task_id>\d+)/$", views.tasks_dropped),
5458
re_path(r"^tasks/get/selfextracted/(?P<task_id>\d+)/$", views.tasks_selfextracted),

web/apiv2/views.py

Lines changed: 245 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import socket
77
import subprocess
88
import sys
9+
import tempfile
910
import zipfile
1011
from datetime import datetime, timedelta
1112
from io import BytesIO
@@ -1653,34 +1654,260 @@ def tasks_pcap(request, task_id):
16531654
return Response(resp)
16541655

16551656

1656-
@csrf_exempt
1657-
@api_view(["GET"])
1658-
def tasks_tlspcap(request, task_id):
1659-
if not apiconf.tasktlspcap.get("enabled"):
1660-
resp = {"error": True, "error_value": "TLS PCAP download API is disabled"}
1661-
return Response(resp)
1657+
def _resolve_task_id(task_id, enabled_key, check_tlp=True):
1658+
"""Shared preamble for artifact-download endpoints.
16621659
1660+
Returns ((task_id, None)) on success or ((None, Response(error))) on failure.
1661+
`enabled_key` names the apiconf section that gates the endpoint; callers
1662+
that want to share a gate (e.g. all pcap variants under [taskpcap]) reuse
1663+
the same key. TLP:RED checks are skipped only for endpoints that need
1664+
to serve regardless (none at present)."""
1665+
section = getattr(apiconf, enabled_key, None)
1666+
if section is not None and not section.get("enabled"):
1667+
return None, Response({"error": True, "error_value": "%s download API is disabled" % enabled_key})
16631668
check = validate_task(task_id)
16641669
if check["error"]:
1665-
return Response(check)
1666-
1670+
return None, Response(check)
1671+
if check_tlp and (check.get("tlp") or "").lower() == "red":
1672+
return None, Response({"error": True, "error_value": "Task has a TLP of RED"})
16671673
rtid = check.get("rtid", 0)
16681674
if rtid:
16691675
task_id = rtid
1676+
return task_id, None
1677+
16701678

1671-
srcfile = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "polarproxy", "tls.pcap")
1679+
def _serve_analysis_file(task_id, rel_path, download_name, content_type="application/octet-stream"):
1680+
"""Stream `<analysis>/<rel_path>` back as an attachment. Returns a Response
1681+
object (either a StreamingHttpResponse for success, or a JSON error)."""
1682+
srcfile = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, rel_path)
16721683
if not os.path.normpath(srcfile).startswith(ANALYSIS_BASE_PATH):
1673-
return render(request, "error.html", {"error": f"File not found: {os.path.basename(srcfile)}"})
1674-
if path_exists(srcfile):
1675-
fname = "%s_tls.pcap" % task_id
1676-
resp = StreamingHttpResponse(FileWrapper(open(srcfile, "rb"), 8096), content_type="application/vnd.tcpdump.pcap")
1677-
resp["Content-Length"] = os.path.getsize(srcfile)
1678-
resp["Content-Disposition"] = "attachment; filename=" + fname
1684+
return Response({"error": True, "error_value": "Invalid path"})
1685+
if not path_exists(srcfile) or os.path.getsize(srcfile) == 0:
1686+
return Response({"error": True, "error_value": f"{os.path.basename(rel_path)} does not exist"})
1687+
resp = StreamingHttpResponse(FileWrapper(open(srcfile, "rb"), 8192), content_type=content_type)
1688+
resp["Content-Length"] = os.path.getsize(srcfile)
1689+
resp["Content-Disposition"] = f"attachment; filename={task_id}_{download_name}"
1690+
return resp
1691+
1692+
1693+
def _zip_paths(task_id, pairs, download_name):
1694+
"""Zip (archive_name, absolute_path) pairs into a disk-backed temporary archive and
1695+
return it as a StreamingHttpResponse. Missing / empty sources are skipped."""
1696+
buf = tempfile.NamedTemporaryFile(delete=True)
1697+
written = 0
1698+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
1699+
for arcname, p in pairs:
1700+
if path_exists(p) and os.path.getsize(p) > 0:
1701+
zf.write(p, arcname)
1702+
written += 1
1703+
if not written:
1704+
buf.close()
1705+
return Response({"error": True, "error_value": "No artifacts available for this task"})
1706+
buf.seek(0, os.SEEK_END)
1707+
size = buf.tell()
1708+
buf.seek(0)
1709+
resp = StreamingHttpResponse(FileWrapper(buf, 8192), content_type="application/zip")
1710+
resp["Content-Length"] = size
1711+
resp["Content-Disposition"] = f"attachment; filename={task_id}_{download_name}"
1712+
return resp
1713+
1714+
1715+
def _serve_folder_zip(task_id, rel_folder, download_name, empty_msg=None):
1716+
"""Encrypt-zip an entire directory under the analysis dir and stream it.
1717+
Uses `create_zip` (password = ZIP_PWD) for parity with tasks_dropped /
1718+
tasks_payloadfiles. Returns a Response with a JSON error if the folder
1719+
doesn't exist or is empty."""
1720+
srcdir = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, rel_folder)
1721+
if not os.path.normpath(srcdir).startswith(ANALYSIS_BASE_PATH):
1722+
return Response({"error": True, "error_value": "Invalid path"})
1723+
if not path_exists(srcdir) or not os.listdir(srcdir):
1724+
return Response({"error": True, "error_value": empty_msg or f"No {rel_folder} artifacts for task {task_id}"})
1725+
mem_zip = create_zip(folder=srcdir, encrypted=True, temp_file=True)
1726+
if mem_zip is False:
1727+
return Response({"error": True, "error_value": "Can't create zip archive"})
1728+
mem_zip.seek(0, os.SEEK_END)
1729+
size = mem_zip.tell()
1730+
mem_zip.seek(0)
1731+
resp = StreamingHttpResponse(FileWrapper(mem_zip, 8192), content_type="application/zip")
1732+
resp["Content-Length"] = size
1733+
resp["Content-Disposition"] = f"attachment; filename={task_id}_{download_name}"
1734+
return resp
1735+
1736+
1737+
@csrf_exempt
1738+
@api_view(["GET"])
1739+
def tasks_tlspcap(request, task_id):
1740+
"""Back-compat endpoint: originally served PolarProxy's tls.pcap. We've
1741+
since moved to SSLproxy + GoGoRoboCap which produces dump_decrypted.pcap;
1742+
prefer that, but fall back to the legacy path for old analyses."""
1743+
task_id, err = _resolve_task_id(task_id, "tasktlspcap", check_tlp=False)
1744+
if err:
1745+
return err
1746+
1747+
decrypted = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "dump_decrypted.pcap")
1748+
legacy = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id, "polarproxy", "tls.pcap")
1749+
for srcfile, fname in ((decrypted, "dump_decrypted.pcap"), (legacy, "tls.pcap")):
1750+
if not os.path.normpath(srcfile).startswith(ANALYSIS_BASE_PATH):
1751+
continue
1752+
if path_exists(srcfile) and os.path.getsize(srcfile) > 0:
1753+
resp = StreamingHttpResponse(
1754+
FileWrapper(open(srcfile, "rb"), 8096), content_type="application/vnd.tcpdump.pcap"
1755+
)
1756+
resp["Content-Length"] = os.path.getsize(srcfile)
1757+
resp["Content-Disposition"] = f"attachment; filename={task_id}_{fname}"
1758+
return resp
1759+
return Response({"error": True, "error_value": "TLS PCAP does not exist"})
1760+
1761+
1762+
# Variant tables used by the consolidated dispatcher endpoints. Each handler
1763+
# validates <variant> against a whitelist before touching the filesystem so
1764+
# the URL parameter can't be used to probe paths outside the analysis dir.
1765+
1766+
_PCAP_VARIANTS = {
1767+
"decrypted": ("dump_decrypted.pcap", "dump_decrypted.pcap"),
1768+
"mixed": ("dump_mixed.pcap", "dump_mixed.pcap"),
1769+
"sslproxy": (os.path.join("sslproxy", "sslproxy.pcap"), "sslproxy.pcap"),
1770+
}
1771+
1772+
_KEY_SOURCES = {
1773+
"tls": (os.path.join("tlsdump", "tlsdump.log"), "tlsdump.log"),
1774+
"ssl": (os.path.join("aux", "sslkeylogfile", "sslkeys.log"), "sslkeys.log"),
1775+
"master": (os.path.join("sslproxy", "master_keys.log"), "master_keys.log"),
1776+
}
1777+
1778+
_ETW_JSON_SOURCES = {
1779+
"dns": (os.path.join("aux", "dns_etw.json"), "dns_etw.json"),
1780+
"network": (os.path.join("aux", "network_etw.json"), "network_etw.json"),
1781+
"wmi": (os.path.join("aux", "wmi_etw.json"), "wmi_etw.json"),
1782+
}
1783+
1784+
_BULKZIP_FOLDERS = {"logs", "network", "memory", "selfextracted"}
1785+
1786+
1787+
def _pcapng_response(task_id):
1788+
"""On-the-fly PCAPNG with TLS keylog records embedded. Output goes to
1789+
a per-request tempfile — concurrent callers must not race on a shared
1790+
path inside the analysis dir."""
1791+
try:
1792+
from lib.cuckoo.common.pcap_utils import PcapToNg
1793+
except ImportError:
1794+
return Response({"error": True, "error_value": "PCAPNG conversion helper unavailable"})
1795+
adir = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id)
1796+
pcap_path = os.path.join(adir, "dump.pcap")
1797+
if not path_exists(pcap_path):
1798+
return Response({"error": True, "error_value": "dump.pcap does not exist"})
1799+
tls_log_path = os.path.join(adir, "tlsdump", "tlsdump.log")
1800+
ssl_key_log_path = os.path.join(adir, "aux", "sslkeylogfile", "sslkeys.log")
1801+
tmp = tempfile.NamedTemporaryFile(prefix=f"{task_id}_pcapng_", suffix=".pcapng", delete=False)
1802+
tmp.close()
1803+
try:
1804+
PcapToNg(pcap_path, tls_log_path, ssl_key_log_path).generate(tmp.name)
1805+
if not path_exists(tmp.name) or os.path.getsize(tmp.name) == 0:
1806+
return Response({"error": True, "error_value": "PCAPNG generation failed"})
1807+
size = os.path.getsize(tmp.name)
1808+
# Hand the open fd to the streaming response; unlinking the path now
1809+
# keeps the fd alive through streaming and lets the kernel reclaim
1810+
# the inode as soon as the response finishes.
1811+
fd = open(tmp.name, "rb")
1812+
try:
1813+
os.unlink(tmp.name)
1814+
except OSError:
1815+
pass
1816+
resp = StreamingHttpResponse(FileWrapper(fd, 8192), content_type="application/x-pcapng")
1817+
resp["Content-Length"] = size
1818+
resp["Content-Disposition"] = f"attachment; filename={task_id}_dump.pcapng"
16791819
return resp
1820+
except Exception:
1821+
try:
1822+
os.unlink(tmp.name)
1823+
except OSError:
1824+
pass
1825+
raise
16801826

1681-
else:
1682-
resp = {"error": True, "error_value": "TLS PCAP does not exist"}
1683-
return Response(resp)
1827+
1828+
def _pcapzip_response(task_id):
1829+
"""Zip every available pcap variant (original, decrypted, mixed, sslproxy
1830+
raw, sslproxy cleaned). Variants that are missing or empty are silently
1831+
dropped so consumers only receive what actually ran."""
1832+
adir = os.path.join(CUCKOO_ROOT, "storage", "analyses", "%s" % task_id)
1833+
pairs = [
1834+
("dump.pcap", os.path.join(adir, "dump.pcap")),
1835+
("dump_decrypted.pcap", os.path.join(adir, "dump_decrypted.pcap")),
1836+
("dump_mixed.pcap", os.path.join(adir, "dump_mixed.pcap")),
1837+
("sslproxy.pcap", os.path.join(adir, "sslproxy", "sslproxy.pcap")),
1838+
("sslproxy_clean.pcap", os.path.join(adir, "sslproxy", "sslproxy_clean.pcap")),
1839+
]
1840+
return _zip_paths(task_id, pairs, "pcaps.zip")
1841+
1842+
1843+
@csrf_exempt
1844+
@api_view(["GET"])
1845+
def tasks_pcap_variant(request, task_id, variant):
1846+
"""Alternate PCAP artifacts for <task_id>. variant ∈
1847+
{decrypted, mixed, sslproxy, zip, pcapng}. The bare tasks/get/pcap/<id>/
1848+
remains for back-compat with existing callers (serves dump.pcap)."""
1849+
task_id, err = _resolve_task_id(task_id, "taskpcap")
1850+
if err:
1851+
return err
1852+
v = (variant or "").lower()
1853+
if v in _PCAP_VARIANTS:
1854+
rel_path, fname = _PCAP_VARIANTS[v]
1855+
return _serve_analysis_file(task_id, rel_path, fname, content_type="application/vnd.tcpdump.pcap")
1856+
if v == "zip":
1857+
return _pcapzip_response(task_id)
1858+
if v == "pcapng":
1859+
return _pcapng_response(task_id)
1860+
return Response({"error": True, "error_value": f"Unknown pcap variant: {variant}"})
1861+
1862+
1863+
@csrf_exempt
1864+
@api_view(["GET"])
1865+
def tasks_keys(request, task_id, kind):
1866+
"""TLS keylog material. kind ∈ {tls, ssl, master} — each refers to a
1867+
different hook source (tls: MockSSL → tlsdump.log; ssl: bcrypt/NCrypt →
1868+
aux/sslkeylogfile/sslkeys.log; master: SSLproxy → master_keys.log).
1869+
All three are NSS-format keylogs."""
1870+
task_id, err = _resolve_task_id(task_id, "tasktlskeys")
1871+
if err:
1872+
return err
1873+
k = (kind or "").lower()
1874+
if k not in _KEY_SOURCES:
1875+
return Response({"error": True, "error_value": f"Unknown keys kind: {kind}"})
1876+
rel_path, fname = _KEY_SOURCES[k]
1877+
return _serve_analysis_file(task_id, rel_path, fname, content_type="text/plain")
1878+
1879+
1880+
@csrf_exempt
1881+
@api_view(["GET"])
1882+
def tasks_etw(request, task_id, kind):
1883+
"""ETW telemetry downloads. kind ∈ {dns, network, wmi} each map to an
1884+
NDJSON stream; kind == amsi zips the per-buffer AMSI script captures."""
1885+
task_id, err = _resolve_task_id(task_id, "tasketw")
1886+
if err:
1887+
return err
1888+
k = (kind or "").lower()
1889+
if k in _ETW_JSON_SOURCES:
1890+
rel_path, fname = _ETW_JSON_SOURCES[k]
1891+
return _serve_analysis_file(task_id, rel_path, fname, content_type="application/x-ndjson")
1892+
if k == "amsi":
1893+
return _serve_folder_zip(task_id, os.path.join("aux", "amsi_etw"), "amsi_etw.zip")
1894+
return Response({"error": True, "error_value": f"Unknown etw kind: {kind}"})
1895+
1896+
1897+
@csrf_exempt
1898+
@api_view(["GET"])
1899+
def tasks_bulkzip(request, task_id, folder):
1900+
"""Encrypt-zip an entire analysis subdirectory. folder is whitelisted
1901+
to {logs, network, memory, selfextracted}. Archive is AES-encrypted
1902+
with ZIP_PWD for parity with tasks_dropped / tasks_payloadfiles /
1903+
tasks_procdumpfiles."""
1904+
task_id, err = _resolve_task_id(task_id, "taskbulkzip")
1905+
if err:
1906+
return err
1907+
f = (folder or "").lower()
1908+
if f not in _BULKZIP_FOLDERS:
1909+
return Response({"error": True, "error_value": f"Unknown bulkzip folder: {folder}"})
1910+
return _serve_folder_zip(task_id, f, f"{f}.zip")
16841911

16851912

16861913
@csrf_exempt

0 commit comments

Comments
 (0)