sandbox-cli 0.2.26__py3-none-any.whl

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.
Files changed (36) hide show
  1. sandbox_cli/__main__.py +28 -0
  2. sandbox_cli/cli/__init__.py +46 -0
  3. sandbox_cli/cli/downloader.py +308 -0
  4. sandbox_cli/cli/images.py +45 -0
  5. sandbox_cli/cli/reporter.py +146 -0
  6. sandbox_cli/cli/rules/__init__.py +83 -0
  7. sandbox_cli/cli/scanner/__init__.py +681 -0
  8. sandbox_cli/cli/unpack.py +50 -0
  9. sandbox_cli/console.py +24 -0
  10. sandbox_cli/internal/__init__.py +0 -0
  11. sandbox_cli/internal/config.py +143 -0
  12. sandbox_cli/internal/helpers.py +32 -0
  13. sandbox_cli/models/__init__.py +0 -0
  14. sandbox_cli/models/detections.py +91 -0
  15. sandbox_cli/utils/__init__.py +0 -0
  16. sandbox_cli/utils/compiler/__init__.py +60 -0
  17. sandbox_cli/utils/compiler/abc.py +51 -0
  18. sandbox_cli/utils/compiler/docker.py +136 -0
  19. sandbox_cli/utils/compiler/ssh.py +203 -0
  20. sandbox_cli/utils/downloader/__init__.py +178 -0
  21. sandbox_cli/utils/extractors.py +66 -0
  22. sandbox_cli/utils/merge_dll_hooks.py +84 -0
  23. sandbox_cli/utils/scanner/__init__.py +310 -0
  24. sandbox_cli/utils/scanner/advanced.py +360 -0
  25. sandbox_cli/utils/scanner/rescan.py +258 -0
  26. sandbox_cli/utils/unpack/__init__.py +96 -0
  27. sandbox_cli/utils/unpack/plugins/__init__.py +0 -0
  28. sandbox_cli/utils/unpack/plugins/abc.py +18 -0
  29. sandbox_cli/utils/unpack/plugins/correlation.py +52 -0
  30. sandbox_cli/utils/unpack/plugins/sort_by_plugins.py +30 -0
  31. sandbox_cli-0.2.26.dist-info/METADATA +139 -0
  32. sandbox_cli-0.2.26.dist-info/RECORD +36 -0
  33. sandbox_cli-0.2.26.dist-info/WHEEL +4 -0
  34. sandbox_cli-0.2.26.dist-info/entry_points.txt +2 -0
  35. sandbox_cli-0.2.26.dist-info/licenses/LICENSE +21 -0
  36. sandbox_cli-0.2.26.dist-info/licenses/NOTICE +322 -0
@@ -0,0 +1,28 @@
1
+ import sys
2
+ from http import HTTPStatus
3
+
4
+ import aiohttp
5
+ import aiohttp.client_exceptions
6
+
7
+ from sandbox_cli.cli import app
8
+ from sandbox_cli.console import console
9
+
10
+
11
+ def main() -> None:
12
+ if sys.platform == "win32":
13
+ import asyncio
14
+
15
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
16
+
17
+ try:
18
+ app()
19
+ except aiohttp.client_exceptions.ClientResponseError as e:
20
+ # global handler for 401 error
21
+ if e.status == HTTPStatus.UNAUTHORIZED:
22
+ console.error(f"The specified token is not valid. {e}")
23
+ except Exception:
24
+ console.print_exception()
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,46 @@
1
+ """
2
+ Register all commands here
3
+ """
4
+
5
+ from cyclopts import App
6
+
7
+ from sandbox_cli.cli.reporter import generate_report
8
+ from sandbox_cli.cli.unpack import unpack_logs
9
+ from sandbox_cli.console import console
10
+ from sandbox_cli.internal.config import configpath, settings
11
+
12
+
13
+ def get_version() -> str:
14
+ import importlib.metadata
15
+
16
+ version = importlib.metadata.version("sandbox-cli")
17
+ return f"sandbox-cli {version}"
18
+
19
+
20
+ app = App(
21
+ name="sandbox-cli",
22
+ help="Work with sandbox like a pro"
23
+ + (
24
+ f"\n\nTo access other commands, specify at least one sandbox in the config at **{configpath}**"
25
+ if len(settings.sandbox_keys) == 0
26
+ else ""
27
+ ),
28
+ help_format="markdown",
29
+ version=get_version,
30
+ console=console,
31
+ )
32
+
33
+ app.command(name=["conv", "unpack"])(unpack_logs)
34
+ app.command(name="report")(generate_report)
35
+
36
+ if len(settings.sandbox_keys) > 0:
37
+ from sandbox_cli.cli.downloader import download_command, download_email
38
+ from sandbox_cli.cli.images import get_images
39
+ from sandbox_cli.cli.rules import rules
40
+ from sandbox_cli.cli.scanner import scanner
41
+
42
+ app.command(scanner)
43
+ app.command(rules)
44
+ app.command(name="images")(get_images)
45
+ app.command(name="download")(download_command)
46
+ app.command(name="email")(download_email)
@@ -0,0 +1,308 @@
1
+ import asyncio
2
+ from collections.abc import Coroutine
3
+ from http import HTTPStatus
4
+ from pathlib import Path
5
+ from typing import Annotated, Any
6
+ from urllib.parse import urlparse
7
+ from uuid import UUID
8
+
9
+ import aiofiles
10
+ import aiohttp
11
+ import aiohttp.client_exceptions
12
+ from cyclopts import Parameter
13
+ from ptsandbox import Sandbox
14
+ from ptsandbox.models import Artifact, SandboxBaseTaskResponse
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
16
+
17
+ from sandbox_cli.console import console
18
+ from sandbox_cli.internal.config import settings
19
+ from sandbox_cli.internal.helpers import (
20
+ get_key_by_name,
21
+ get_sandbox_key_by_host,
22
+ validate_key,
23
+ )
24
+ from sandbox_cli.utils.downloader import download
25
+ from sandbox_cli.utils.unpack import Unpack
26
+
27
+
28
+ def get_key_and_task(key: str, task: str) -> tuple[Sandbox, UUID] | tuple[None, None]:
29
+ try:
30
+ uuid = UUID(task)
31
+ sandbox_key = get_key_by_name(key)
32
+ return Sandbox(sandbox_key), uuid
33
+ except ValueError:
34
+ url = urlparse(task)
35
+ if not (url.scheme and url.path and "/tasks" in url.path):
36
+ console.warning(f"Invalid task_id: {task}")
37
+ return None, None
38
+
39
+ try:
40
+ uuid = UUID(url.path.split("/")[2])
41
+ sandbox_key = get_sandbox_key_by_host(url.hostname or "")
42
+ return Sandbox(sandbox_key), uuid
43
+ except ValueError:
44
+ console.error(f"Invalid task id: {task}")
45
+ return None, None
46
+
47
+
48
+ async def download_command(
49
+ tasks_id: Annotated[
50
+ list[str],
51
+ Parameter(
52
+ help="Links to tasks or task ids",
53
+ negative="",
54
+ required=True,
55
+ ),
56
+ ],
57
+ /,
58
+ *,
59
+ key: Annotated[
60
+ str,
61
+ Parameter(
62
+ name=["--key", "-k"],
63
+ help=f"The key to access the sandbox **{'**,**'.join(x.name for x in settings.sandbox_keys)}**",
64
+ validator=validate_key,
65
+ group="Sandbox",
66
+ ),
67
+ ] = settings.sandbox_keys[0].name,
68
+ out_dir: Annotated[
69
+ Path,
70
+ Parameter(
71
+ name=["--out", "-o"],
72
+ help="Output directory",
73
+ ),
74
+ ] = Path("./downloads"),
75
+ decompress: Annotated[
76
+ bool,
77
+ Parameter(
78
+ name=["--decompress", "-D"],
79
+ help="Decompress downloaded files",
80
+ negative="",
81
+ ),
82
+ ] = False,
83
+ unpack: Annotated[
84
+ bool,
85
+ Parameter(
86
+ name=["--unpack", "-U"],
87
+ help="Unpack downloaded files",
88
+ negative="",
89
+ ),
90
+ ] = False,
91
+ all: Annotated[
92
+ bool,
93
+ Parameter(
94
+ name=["--all", "-a"],
95
+ help="Download all artifacts",
96
+ negative="",
97
+ group="Download options",
98
+ ),
99
+ ] = False,
100
+ debug: Annotated[
101
+ bool,
102
+ Parameter(
103
+ name=["--debug", "-d"],
104
+ help="Download debug artifacts",
105
+ negative="",
106
+ group="Download options",
107
+ ),
108
+ ] = False,
109
+ artifacts: Annotated[
110
+ bool,
111
+ Parameter(
112
+ name=["--artifacts", "-A"],
113
+ help="Download artifacts",
114
+ negative="",
115
+ group="Download options",
116
+ ),
117
+ ] = False,
118
+ files: Annotated[
119
+ bool,
120
+ Parameter(
121
+ name=["--files", "-f"],
122
+ help="Download files",
123
+ negative="",
124
+ group="Download options",
125
+ ),
126
+ ] = False,
127
+ crashdumps: Annotated[
128
+ bool,
129
+ Parameter(
130
+ name=["--crashdumps", "-c"],
131
+ help="Download crashdumps (maybe be more 1GB)",
132
+ negative="",
133
+ group="Download options",
134
+ ),
135
+ ] = False,
136
+ procdumps: Annotated[
137
+ bool,
138
+ Parameter(
139
+ name=["--procdumps", "-p"],
140
+ help="Download procdumps",
141
+ negative="",
142
+ group="Download options",
143
+ ),
144
+ ] = False,
145
+ video: Annotated[
146
+ bool,
147
+ Parameter(
148
+ name=["--video", "-v"],
149
+ help="Download video",
150
+ negative="",
151
+ group="Download options",
152
+ ),
153
+ ] = False,
154
+ logs: Annotated[
155
+ bool,
156
+ Parameter(
157
+ name=["--logs", "-l"],
158
+ help="Download logs",
159
+ negative="",
160
+ group="Download options",
161
+ ),
162
+ ] = False,
163
+ ) -> None:
164
+ """
165
+ Download any artifact from the sandbox.
166
+ """
167
+
168
+ async def worker(
169
+ report: SandboxBaseTaskResponse.LongReport,
170
+ sandbox: Sandbox,
171
+ out_dir: Path,
172
+ all: bool = False,
173
+ artifacts: bool = False,
174
+ crashdumps: bool = False,
175
+ debug: bool = False,
176
+ decompress: bool = False,
177
+ files: bool = False,
178
+ logs: bool = False,
179
+ procdumps: bool = False,
180
+ video: bool = False,
181
+ progress: Progress | None = None,
182
+ ) -> None:
183
+ await download(
184
+ report=report,
185
+ sandbox=sandbox,
186
+ out_dir=out_dir,
187
+ all=all,
188
+ debug=debug,
189
+ artifacts=artifacts,
190
+ files=files,
191
+ crashdumps=crashdumps,
192
+ procdumps=procdumps,
193
+ video=video,
194
+ logs=logs,
195
+ decompress=decompress,
196
+ progress=progress,
197
+ )
198
+
199
+ if unpack and out_dir.exists():
200
+ Unpack(out_dir).run()
201
+
202
+ progress = Progress(
203
+ SpinnerColumn(),
204
+ TextColumn("{task.description}"),
205
+ TimeElapsedColumn(),
206
+ console=console,
207
+ transient=True,
208
+ )
209
+
210
+ tasks: list[Coroutine[Any, Any, Artifact.EngineResult | None]] = []
211
+
212
+ for task in tasks_id:
213
+ sandbox, task_id = get_key_and_task(key, task)
214
+ if not sandbox or not task_id:
215
+ continue
216
+
217
+ try:
218
+ result = await sandbox.get_report(task_id=task_id)
219
+ except aiohttp.client_exceptions.ClientResponseError as e:
220
+ if e.status == HTTPStatus.NOT_FOUND:
221
+ console.warning(
222
+ f"The report cannot be accessed from {task_id}.\n"
223
+ "Read more about it in: https://security-experts-community.github.io/py-ptsandbox/usage/public-api/download-files/"
224
+ )
225
+ continue
226
+
227
+ if (report := result.get_long_report()) is None:
228
+ console.warning(
229
+ f"The report cannot be accessed from {task_id}.\n"
230
+ "Read more about it in: https://security-experts-community.github.io/py-ptsandbox/usage/public-api/download-files/"
231
+ )
232
+ continue
233
+
234
+ tasks.append(
235
+ worker(
236
+ report=report,
237
+ sandbox=sandbox,
238
+ out_dir=out_dir / str(task_id),
239
+ all=all,
240
+ debug=debug,
241
+ artifacts=artifacts,
242
+ files=files,
243
+ crashdumps=crashdumps,
244
+ procdumps=procdumps,
245
+ video=video,
246
+ logs=logs,
247
+ decompress=decompress,
248
+ progress=progress,
249
+ )
250
+ )
251
+
252
+ with progress:
253
+ await asyncio.gather(*tasks)
254
+
255
+
256
+ def download_email(
257
+ emails: Annotated[
258
+ list[Path],
259
+ Parameter(
260
+ help="The path to the email files",
261
+ ),
262
+ ],
263
+ /,
264
+ *,
265
+ out_dir: Annotated[
266
+ Path,
267
+ Parameter(
268
+ name=["--out", "-o"],
269
+ help="Output directory",
270
+ ),
271
+ ] = Path("./downloads"),
272
+ key: Annotated[
273
+ str,
274
+ Parameter(
275
+ name=["--key", "-k"],
276
+ help=f"The key to access the sandbox **{'**,**'.join(x.name for x in settings.sandbox_keys)}**",
277
+ validator=validate_key,
278
+ group="Sandbox",
279
+ ),
280
+ ] = settings.sandbox_keys[0].name,
281
+ ) -> None:
282
+ """
283
+ Upload an email and get its headers.
284
+ """
285
+
286
+ # some path prepartions
287
+ out_dir.mkdir(exist_ok=True, parents=True)
288
+ out_dir = out_dir.expanduser().resolve()
289
+
290
+ async def _func(out_dir: Path) -> None:
291
+ sandbox = Sandbox(get_key_by_name(key))
292
+
293
+ async def _internal(email: Path, out_dir: Path) -> None:
294
+ async with aiofiles.open(out_dir / f"{email}.headers", "wb") as fd:
295
+ async for chunk in sandbox.get_email_headers(email):
296
+ await fd.write(chunk)
297
+
298
+ # small validation
299
+ files: list[Path] = []
300
+ for email in emails:
301
+ if not email.exists():
302
+ console.warning(f"{email} doesn't exists")
303
+ continue
304
+ files.append(email)
305
+
306
+ await asyncio.gather(*(_internal(email, out_dir) for email in files))
307
+
308
+ asyncio.run(_func(out_dir))
@@ -0,0 +1,45 @@
1
+ from typing import Annotated
2
+
3
+ from cyclopts import Parameter
4
+ from ptsandbox import Sandbox
5
+ from rich.table import Table
6
+
7
+ from sandbox_cli.console import console
8
+ from sandbox_cli.internal.config import settings
9
+ from sandbox_cli.internal.helpers import get_key_by_name, validate_key
10
+
11
+
12
+ async def get_images(
13
+ *,
14
+ key: Annotated[
15
+ str,
16
+ Parameter(
17
+ name=["--key", "-k"],
18
+ help=f"The key to access the sandbox **{'**,**'.join(x.name for x in settings.sandbox_keys)}**",
19
+ validator=validate_key,
20
+ group="Sandbox",
21
+ ),
22
+ ] = settings.sandbox_keys[0].name,
23
+ ) -> None:
24
+ """
25
+ Get available images in the sandbox.
26
+ """
27
+
28
+ sandbox = Sandbox(get_key_by_name(key))
29
+ images = await sandbox.get_images()
30
+
31
+ table = Table()
32
+ table.add_column("Name")
33
+ table.add_column("Image ID", style="turquoise2")
34
+ table.add_column("Version")
35
+ table.add_column("Product version")
36
+ table.add_column("Locale")
37
+
38
+ for image in images:
39
+ if not image.os:
40
+ console.warning(f"{image.image_id} doesn't contain OS information")
41
+ continue
42
+
43
+ table.add_row(image.os.name, image.image_id, image.os.version, image.version, image.os.locale)
44
+
45
+ console.print(table)
@@ -0,0 +1,146 @@
1
+ import datetime
2
+ import gzip
3
+ import textwrap
4
+ from pathlib import Path
5
+ from typing import Annotated, Literal, TypedDict
6
+
7
+ from cyclopts import Parameter
8
+ from ptsandbox.models import SandboxBaseTaskResponse
9
+ from rich.table import Table
10
+
11
+ from sandbox_cli.console import console
12
+ from sandbox_cli.utils.extractors import (
13
+ extract_memory,
14
+ extract_network_from_trace,
15
+ extract_static,
16
+ extract_verdict_from_trace,
17
+ )
18
+
19
+
20
+ class TableData(TypedDict):
21
+ sample: str
22
+ image: str
23
+ sandbox: str
24
+ verdict: str
25
+ static: str
26
+ memory: str
27
+ network: str
28
+
29
+
30
+ def generate_report(
31
+ src: Annotated[
32
+ list[Path],
33
+ Parameter(
34
+ help="Folder(s) with sandbox reports (recursive search will be used)",
35
+ ),
36
+ ],
37
+ /,
38
+ *,
39
+ mode: Annotated[
40
+ Literal["cli", "md"],
41
+ Parameter(
42
+ name=["--mode", "-m"],
43
+ help="Report output format",
44
+ ),
45
+ ] = "cli",
46
+ latest: Annotated[
47
+ bool,
48
+ Parameter(
49
+ name=["--latest", "-l"],
50
+ help="Reports created in the last 2 hours",
51
+ negative="",
52
+ ),
53
+ ] = False,
54
+ ) -> None:
55
+ """
56
+ Generate short report from sandbox scans.
57
+ """
58
+
59
+ data: list[TableData] = []
60
+ match mode:
61
+ case "md":
62
+ delimeter = " "
63
+ case _:
64
+ delimeter = "\n"
65
+
66
+ for directory in src:
67
+ for root in directory.rglob("*"):
68
+ if not root.is_dir():
69
+ continue
70
+
71
+ report_file = root / "report.json"
72
+ if not report_file.exists():
73
+ continue
74
+
75
+ if latest and (
76
+ datetime.datetime.now() - datetime.datetime.fromtimestamp(report_file.stat().st_mtime)
77
+ ) > datetime.timedelta(hours=2):
78
+ continue
79
+
80
+ with open(report_file, encoding="utf-8") as fd:
81
+ report_data = fd.read()
82
+ scan_data = SandboxBaseTaskResponse.model_validate_json(report_data)
83
+
84
+ if (report := scan_data.get_long_report()) is None:
85
+ console.warning(f"A report without behavioral analysis: {root}")
86
+ continue
87
+
88
+ corr_trace_path = root / "events-correlated.log.gz"
89
+ if not corr_trace_path.exists():
90
+ corr_trace_path = root / "raw" / "events-correlated.log.gz"
91
+
92
+ if not corr_trace_path.exists():
93
+ console.error(f"Can't find events-correlated.log.gz: {root}")
94
+ continue
95
+
96
+ corr_trace = gzip.open(corr_trace_path, "rb").read()
97
+ image = root.name
98
+ try:
99
+ image = report.artifacts[0].find_sandbox_result().details.sandbox.image.image_id # type: ignore
100
+ finally:
101
+ if image is None:
102
+ image = root.name
103
+
104
+ data.append(
105
+ {
106
+ "sample": report.artifacts[0].file_info.file_path, # type: ignore
107
+ "image": image,
108
+ "verdict": delimeter.join(extract_verdict_from_trace(corr_trace)),
109
+ "static": delimeter.join(extract_static(report)),
110
+ "memory": delimeter.join(extract_memory(report)),
111
+ "network": delimeter.join(extract_network_from_trace(corr_trace)),
112
+ }
113
+ )
114
+
115
+ match mode:
116
+ case "md":
117
+ md_report_head = textwrap.dedent(
118
+ """
119
+ | Sample | Image | Verdict | Static | Memory | Network |
120
+ | --- | --- | --- | --- | --- | --- |"""
121
+ ).strip()
122
+
123
+ md_report_base = "|{sample}|{image}|{verdict}|{static}|{memory}|{network}|"
124
+ table_md = md_report_head + "\n"
125
+ table_md += "\n".join(md_report_base.format(**d) for d in data)
126
+ print(table_md)
127
+ case "cli":
128
+ table = Table(highlight=True, show_lines=True)
129
+ table.add_column("File", overflow="fold")
130
+ table.add_column("Image", overflow="fold")
131
+ table.add_column("Verdict", overflow="fold", style="bold")
132
+ table.add_column("Static", overflow="fold", style="bold")
133
+ table.add_column("Memory", overflow="fold")
134
+ table.add_column("Network", overflow="fold")
135
+ for d in data:
136
+ table.add_row(
137
+ d["sample"],
138
+ d["image"],
139
+ d["verdict"],
140
+ d["static"],
141
+ d["memory"],
142
+ d["network"],
143
+ )
144
+ console.print(table)
145
+ case _:
146
+ pass
@@ -0,0 +1,83 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ from cyclopts import App, Parameter, validators
5
+
6
+ from sandbox_cli.console import console
7
+ from sandbox_cli.utils.compiler import compile_rules_internal, test_rules_internal
8
+
9
+ rules = App(
10
+ name="rules",
11
+ help="Working with raw sandbox rules.",
12
+ help_format="markdown",
13
+ )
14
+
15
+
16
+ @rules.command(name="compile")
17
+ async def compile_rules(
18
+ *,
19
+ rules: Annotated[
20
+ Path,
21
+ Parameter(
22
+ name=["--rules", "-r"],
23
+ help="The path to the folder with the rules",
24
+ validator=validators.Path(exists=True),
25
+ ),
26
+ ],
27
+ out: Annotated[
28
+ Path,
29
+ Parameter(
30
+ name=["--out", "-o"],
31
+ help="The path where to save the compiled rules",
32
+ required=False,
33
+ ),
34
+ ] = Path("compiled-rules.local.tmp"),
35
+ is_local: Annotated[
36
+ bool,
37
+ Parameter(
38
+ name=["--local", "-l"],
39
+ negative="",
40
+ help="The rules will be compiled locally using Docker (unix only)",
41
+ ),
42
+ ] = False,
43
+ ) -> None:
44
+ """
45
+ Get compiled rules for working with third-party services.
46
+ """
47
+
48
+ out.mkdir(exist_ok=True, parents=True)
49
+ out = out.expanduser().resolve()
50
+
51
+ with console.status(f"{console.INFO} Waiting for the rules to be compiled"):
52
+ data = await compile_rules_internal(rules_dir=rules, is_local=is_local, compiled_rules_dir=out)
53
+ (out / "compiled-rules.local.tar.gz").write_bytes(data)
54
+
55
+ console.info(f"Rules saved to {out / 'compiled-rules.local.tar.gz'}")
56
+
57
+
58
+ @rules.command(name="test")
59
+ async def test_rules(
60
+ *,
61
+ rules: Annotated[
62
+ Path,
63
+ Parameter(
64
+ name=["--rules", "-r"],
65
+ help="The path to the folder with the rules",
66
+ validator=validators.Path(exists=True),
67
+ ),
68
+ ],
69
+ is_local: Annotated[
70
+ bool,
71
+ Parameter(
72
+ name=["--local", "-l"],
73
+ negative="",
74
+ help="The rules will be compiled locally using Docker (unix only)",
75
+ ),
76
+ ] = False,
77
+ ) -> None:
78
+ """
79
+ Testing written rules.
80
+ """
81
+
82
+ with console.status(f"{console.INFO} Testing rules"):
83
+ await test_rules_internal(rules=rules, is_local=is_local)