Feature: Download raw analysis files from web (#1148)

This commit is contained in:
Paweł Srokosz
2025-11-20 19:27:11 +01:00
committed by GitHub
parent 867166b0ff
commit bd6c7a8db3
11 changed files with 425 additions and 1 deletions
+2
View File
@@ -14,5 +14,7 @@ services:
build:
context: ..
dockerfile: ./dev/frontend.Dockerfile
volumes:
- "../drakrun/web/frontend/src:/app/src"
ports:
- "3000:3000"
+21
View File
@@ -28,6 +28,7 @@ from drakrun.lib.config import load_config
from drakrun.lib.paths import UPLOADS_DIR
from drakrun.lib.s3_storage import get_s3_client, is_s3_enabled, upload_sample_to_s3
from drakrun.web.schema import (
AnalysisFileRequestQuery,
AnalysisListResponse,
AnalysisRequestPath,
AnalysisResponse,
@@ -40,6 +41,7 @@ from drakrun.web.schema import (
UploadFileForm,
)
from drakrun.web.storage import (
list_analysis_files,
list_analysis_logs,
open_seekable_stream,
read_analysis_json,
@@ -307,6 +309,25 @@ def list_logs(path: AnalysisRequestPath):
return jsonify(analysis_logs)
@api.get("/files/<task_uid>")
def list_files(path: AnalysisRequestPath):
task_uid = path.task_uid
analysis_files = list_analysis_files(task_uid, s3_config=config.s3)
return jsonify(analysis_files)
@api.get("/files/<task_uid>/download")
def download_analysis_file(path: AnalysisRequestPath, query: AnalysisFileRequestQuery):
task_uid = path.task_uid
return send_analysis_file(
task_uid,
query.filename,
s3_config=config.s3,
mimetype="application/octet-stream",
download_name=query.filename.split("/")[-1],
)
@api.get("/graph/<task_uid>")
def graph(path: AnalysisRequestPath):
task_uid = path.task_uid
+128 -1
View File
@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "@drakvuf-sandbox/web",
"version": "0.19.0",
"version": "0.20.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
@@ -16,10 +16,13 @@
"@novnc/novnc": "^1.5.0",
"axios": "^1.12.0",
"bootstrap": "^5.3.5",
"jszip": "^3.10.1",
"jszip-utils": "^0.1.0",
"prettier": "^3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-medium-image-zoom": "^5.2.14",
"react-modal": "^3.16.3",
"react-router-dom": "^6.30.0",
"react-select": "^5.10.1",
"startbootstrap-sb-admin": "^7.0.7"
@@ -1803,6 +1806,11 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -2180,6 +2188,11 @@
"node": ">=0.10.0"
}
},
"node_modules/exenv": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
"integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2471,6 +2484,11 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2495,6 +2513,11 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -2535,6 +2558,11 @@
"node": ">=0.10.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2605,6 +2633,22 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/jszip-utils/-/jszip-utils-0.1.0.tgz",
"integrity": "sha512-tBNe0o3HAf8vo0BrOYnLPnXNo5A3KsRMnkBFYjh20Y3GPYGfgyoclEMgvVchx0nnL+mherPi74yLPIusHUQpZg=="
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2627,6 +2671,14 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -2812,6 +2864,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -2940,6 +2997,11 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -2988,6 +3050,11 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-medium-image-zoom": {
"version": "5.2.14",
"resolved": "https://registry.npmjs.org/react-medium-image-zoom/-/react-medium-image-zoom-5.2.14.tgz",
@@ -3003,6 +3070,21 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-modal": {
"version": "3.16.3",
"resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz",
"integrity": "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==",
"dependencies": {
"exenv": "^1.2.0",
"prop-types": "^15.7.2",
"react-lifecycles-compat": "^3.0.0",
"warning": "^4.0.3"
},
"peerDependencies": {
"react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19",
"react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3085,6 +3167,20 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
@@ -3156,6 +3252,11 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -3170,6 +3271,11 @@
"semver": "bin/semver.js"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3234,6 +3340,14 @@
"@popperjs/core": "^2.11.6"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -3355,6 +3469,11 @@
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/virtua": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.40.3.tgz",
@@ -3459,6 +3578,14 @@
}
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+3
View File
@@ -18,10 +18,13 @@
"@novnc/novnc": "^1.5.0",
"axios": "^1.12.0",
"bootstrap": "^5.3.5",
"jszip": "^3.10.1",
"jszip-utils": "^0.1.0",
"prettier": "^3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-medium-image-zoom": "^5.2.14",
"react-modal": "^3.16.3",
"react-router-dom": "^6.30.0",
"react-select": "^5.10.1",
"startbootstrap-sb-admin": "^7.0.7"
+214
View File
@@ -0,0 +1,214 @@
import { use, useCallback, useEffect, useRef, useState } from "react";
import { getAnalysisFileList } from "./api.js";
import { CanceledError } from "axios";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faDownload, faFile } from "@fortawesome/free-solid-svg-icons";
import Modal from "react-modal";
import JSZip from "jszip";
import JSZipUtils from "jszip-utils";
let BASE_URL = "";
if (import.meta.env.VITE_API_SERVER) {
BASE_URL = import.meta.env.VITE_API_SERVER;
} else {
BASE_URL = "/api";
}
Modal.setAppElement("#root");
function urlToPromise(url) {
return new Promise(function (resolve, reject) {
JSZipUtils.getBinaryContent(url, function (err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
function FileTree({ fileList, analysisId }) {
return (
<ul
style={{ overflow: "auto", marginLeft: "8pt" }}
className="list-unstyled"
>
{fileList.map((file, idx) => {
return (
<li key={`f-${idx}`}>
<span className="text-nowrap font-monospace">
<FontAwesomeIcon
icon={faFile}
className="me-3 text-primary"
/>
<a
href={`${BASE_URL}/files/${analysisId}/download?filename=${encodeURIComponent(file)}`}
>
{file}
</a>
</span>
</li>
);
})}
</ul>
);
}
export function AnalysisZipDownload({ files, analysisId }) {
const [modalOpened, setModalOpened] = useState(false);
const [modalMessage, setModalMessage] = useState("");
const [downloadFinished, setDownloadFinished] = useState(false);
const [blobURL, setBlobURL] = useState(null);
const modalStyle = {
content: {
top: "50%",
left: "50%",
right: "auto",
bottom: "auto",
marginRight: "-50%",
transform: "translate(-50%, -50%)",
maxHeight: "100%",
},
};
const makeZip = useCallback(() => {
const zip = new JSZip();
for (let file of files) {
zip.file(
file,
urlToPromise(
`${BASE_URL}/files/${analysisId}/download?filename=${encodeURIComponent(file)}`,
),
{ binary: true },
);
}
zip.generateAsync({ type: "blob" }, (metadata) => {
let msg = "progression : " + metadata.percent.toFixed(2) + " %";
if (metadata.currentFile) {
msg += ", current file = " + metadata.currentFile;
}
setModalMessage(msg);
}).then(
(blob) => {
setBlobURL(URL.createObjectURL(blob));
setModalMessage("");
setDownloadFinished(true);
},
(err) => {
setModalMessage(err);
setDownloadFinished(true);
},
);
}, [files, analysisId]);
const startDownload = useCallback(() => {
setDownloadFinished(false);
setModalOpened(true);
makeZip();
}, [makeZip]);
const closeModal = useCallback(() => {
if (blobURL) {
URL.revokeObjectURL(blobURL);
setBlobURL(null);
}
setModalOpened(false);
}, [blobURL]);
return (
<>
<Modal
isOpen={modalOpened}
onRequestClose={downloadFinished ? closeModal : undefined}
contentLabel="Downloading analysis..."
style={modalStyle}
>
<div className="text-center">
{downloadFinished ? (
blobURL ? (
<div>
<strong>
Analysis downloaded successfully.
</strong>
<br />
Click on the link below to store it:
</div>
) : (
<div>Download failed</div>
)
) : (
<div>Downloading analysis files...</div>
)}
<span className="text-muted">{modalMessage}</span>
{downloadFinished ? (
<>
{blobURL ? (
<div>
<a
href={blobURL}
download={`${analysisId}.zip`}
>{`${analysisId}.zip`}</a>
</div>
) : (
[]
)}
</>
) : (
[]
)}
</div>
</Modal>
<div>
<button
className="btn btn-primary mb-2"
onClick={() => startDownload()}
>
<FontAwesomeIcon icon={faDownload} className="me-2" />
Download all
</button>
</div>
</>
);
}
export function AnalysisFilesViewer({ analysisId }) {
const [files, setFiles] = useState();
const [error, setError] = useState();
useEffect(() => {
const abortController = new AbortController();
setFiles(undefined);
getAnalysisFileList({ analysisId, abortController })
.then((response) => {
setFiles(response.slice().sort());
})
.catch((error) => {
if (!(error instanceof CanceledError)) {
setError(error);
console.error(error);
}
});
return () => {
abortController.abort();
};
}, [analysisId]);
if (typeof error !== "undefined") {
return <div className="text-danger">Error: {error.toString()}</div>;
}
if (typeof files === "undefined") {
return <div>Loading analysis files list...</div>;
}
return (
<div className="container-fluid">
<div className="row">
<AnalysisZipDownload files={files} analysisId={analysisId} />
<FileTree fileList={files} analysisId={analysisId} />
</div>
</div>
);
}
@@ -18,6 +18,7 @@ import { faDownload } from "@fortawesome/free-solid-svg-icons";
import axios, { CanceledError } from "axios";
import { AnalysisSummary } from "./AnalysisSummary.jsx";
import { ProcessBadge } from "./ProcessBadge.jsx";
import { AnalysisFilesViewer } from "./AnalysisFiles.jsx";
export function AnalysisLogViewerTab({ analysisId }) {
const logType = useTabContext()?.activeTab;
@@ -269,6 +270,9 @@ function AnalysisReportTabs({
<AnalysisScreenshotViewer analysis={analysis} />
</PaddedTab>
) : null}
<PaddedTab tab="Analysis files">
<AnalysisFilesViewer analysisId={analysis.id} />
</PaddedTab>
</TabSwitcher>
</div>
</div>
+5
View File
@@ -69,3 +69,8 @@ li.selected {
color: #6c757d;
}
/* Ends overriding */
.ReactModalPortal {
z-index: 10000;
position: absolute;
}
+15
View File
@@ -103,6 +103,21 @@ export async function getProcessLog({
return logRequest.data;
}
export async function getAnalysisFileList({
analysisId,
abortController = undefined,
}) {
const listRequest = await axios.get(
`/files/${analysisId}`,
abortController
? {
signal: abortController.signal,
}
: {},
);
return listRequest.data;
}
export async function uploadSample({
file,
timeout,
+4
View File
@@ -18,6 +18,10 @@ export default defineConfig({
target: process.env.PROXY_BACKEND_URL,
changeOrigin: true,
},
"/openapi": {
target: process.env.PROXY_BACKEND_URL,
changeOrigin: true,
}
} : {}
),
}
+4
View File
@@ -57,6 +57,10 @@ class AnalysisRequestPath(BaseModel):
)
class AnalysisFileRequestQuery(BaseModel):
filename: str
class LogsRequestPath(AnalysisRequestPath):
log_type: str
+25
View File
@@ -232,6 +232,31 @@ def open_seekable_stream(
file.close()
def list_analysis_files(
analysis_id: str, s3_config: S3StorageConfigSection
) -> list[str]:
if not is_s3_enabled(s3_config):
base_path = ANALYSES_DIR / analysis_id
if not base_path.exists():
raise FileNotFoundError
return [
path.relative_to(base_path).as_posix()
for path in base_path.rglob("*")
if path.is_file()
]
# S3 handling
s3_client = get_s3_client(s3_config)
analysis_key = get_s3_prefix(analysis_id) + "/"
response = s3_client.list_objects_v2(Bucket=s3_config.bucket, Prefix=analysis_key)[
"Contents"
]
keys = []
for obj in response:
object_name = obj["Key"][len(analysis_key) :]
keys.append(object_name)
return keys
def list_analysis_logs(analysis_id: str, s3_config: S3StorageConfigSection):
if not is_s3_enabled(s3_config):
base_path = ANALYSES_DIR / analysis_id