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,203 @@
1
+ import os
2
+ import random
3
+ import shutil
4
+ import string
5
+ import sys
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Any, cast
8
+
9
+ from asyncssh import (
10
+ HostKeyNotVerifiable,
11
+ SSHClientConnection,
12
+ SSHClientConnectionOptions,
13
+ SSHClientProcess,
14
+ SSHCompletedProcess,
15
+ SSHReader,
16
+ connect,
17
+ )
18
+
19
+ from sandbox_cli.console import console
20
+ from sandbox_cli.internal.config import settings
21
+ from sandbox_cli.utils.compiler.abc import AbstractCompiler
22
+
23
+
24
+ class RemoteCompiler(AbstractCompiler):
25
+ host: str
26
+ username: str
27
+ password: str
28
+ client: SSHClientConnection | None = None
29
+ tmp_directory: PurePosixPath
30
+
31
+ def __init__(self) -> None:
32
+ super().__init__()
33
+ self.host = settings.sandbox[0].host
34
+ self.username = settings.sandbox[0].ssh.username
35
+ self.password = settings.sandbox[0].ssh.password
36
+
37
+ def _generate_random_string(self) -> str:
38
+ return "".join(random.choice(string.ascii_lowercase) for _ in range(8))
39
+
40
+ async def _init_ssh_client(self) -> None:
41
+ if not self.client:
42
+ try:
43
+ self.client = await connect(
44
+ host=self.host,
45
+ username=self.username,
46
+ password=self.username,
47
+ options=SSHClientConnectionOptions(public_key_auth=False),
48
+ )
49
+ except HostKeyNotVerifiable:
50
+ console.error(f"Can't verify ssh-key. Execute 'ssh {self.username}@{self.host}' and type 'yes'")
51
+ sys.exit(1)
52
+
53
+ async def _run_command(self, command: str, is_sudo: bool = False) -> SSHCompletedProcess:
54
+ await self._init_ssh_client()
55
+ assert self.client is not None
56
+
57
+ if is_sudo:
58
+ command = f"echo {self.password} | sudo -k -S {command}"
59
+
60
+ return await self.client.run(command)
61
+
62
+ async def _run_stream_command(self, command: str, is_sudo: bool = False) -> SSHClientProcess[Any]:
63
+ await self._init_ssh_client()
64
+ assert self.client is not None
65
+
66
+ if is_sudo:
67
+ command = f"echo {self.password} | sudo -k -S {command}"
68
+
69
+ async with self.client.create_process(command) as process:
70
+ async for line in cast(SSHReader[bytes], process.stdout):
71
+ print(f"{line.decode() if isinstance(line, bytes) else line}", end="")
72
+
73
+ return process
74
+
75
+ async def _create_tmp_directory(self) -> None:
76
+ self.tmp_directory = PurePosixPath("/tmp/sandbox-cli") / self._generate_random_string()
77
+ await self._run_command(f"mkdir -p {self.tmp_directory}")
78
+
79
+ async def _upload_rules(self, rules_dir: Path) -> None:
80
+ assert self.client is not None
81
+
82
+ arcname = shutil.make_archive("rules", "zip", rules_dir)
83
+ async with self.client.start_sftp_client() as ftp:
84
+ await ftp.put(arcname, f"{self.tmp_directory}")
85
+ os.remove(arcname)
86
+
87
+ await self._run_command(
88
+ f"""
89
+ mkdir -p {self.tmp_directory / "rules"} &&
90
+ mkdir -p {self.tmp_directory / "compiled-rules"} &&
91
+ unzip -d {self.tmp_directory / "rules"} {self.tmp_directory / "rules.zip"} >/dev/null
92
+ """
93
+ )
94
+
95
+ async def _cleanup(self, rules_dir: Path) -> None:
96
+ await self._run_command(f"rm -rf {self.tmp_directory}")
97
+ shutil.rmtree(rules_dir / "taxonomy")
98
+
99
+ async def _compile_rules_on_server(self) -> None:
100
+ # generate random container name for avoid collision with several peoples
101
+ result = await self._run_command(
102
+ command=f'ctr run --rm --memory-limit=1000000000 \
103
+ --mount type=bind,src={self.tmp_directory / "compiled-rules"},dst=/compiled-rules,options=rbind:rw \
104
+ --mount type=bind,src={self.tmp_directory / "rules"},dst=/rules,options=rbind:rw \
105
+ "{settings.docker.path}:{settings.docker.image_tag}" {self._generate_random_string()} package-builder correlation:compile -r /rules -c /compiled-rules',
106
+ is_sudo=True,
107
+ )
108
+ if result.exit_status:
109
+ console.log("failed to compile rules", style="bold red")
110
+ console.print(result.stderr)
111
+ sys.exit(1)
112
+
113
+ async def _download_compiled_rules(self) -> bytes:
114
+ assert self.client is not None
115
+
116
+ await self._run_command(
117
+ f"tar -C {self.tmp_directory / 'compiled-rules'} -czvf {self.tmp_directory / 'compiled-rules.tar.gz'} event_normalization_graph.json event_correlation_graph.json"
118
+ )
119
+
120
+ async with self.client.start_sftp_client() as ftp:
121
+ fd = await ftp.open(path=self.tmp_directory / "compiled-rules.tar.gz", pflags_or_mode="rb")
122
+ data: bytes = await fd.read()
123
+
124
+ return data
125
+
126
+ async def pull_image(self) -> None:
127
+ """Update image on remote server"""
128
+
129
+ process = await self._run_command(
130
+ f'ctr image pull --user "{settings.docker.username}:{settings.docker.token}" "{settings.docker.path}:{settings.docker.image_tag}"',
131
+ is_sudo=True,
132
+ )
133
+
134
+ if process.exit_status != 0:
135
+ console.error("Failed to update docker image on server")
136
+
137
+ if process.stdout:
138
+ value = process.stdout
139
+ console.log(f"{value.decode() if isinstance(value, bytes) else value}")
140
+ if process.stderr:
141
+ value = process.stderr
142
+ console.log(f"{value.decode() if isinstance(value, bytes) else value}")
143
+
144
+ sys.exit(1)
145
+
146
+ console.info("Docker image successfully updated on server")
147
+
148
+ async def compile_rules(self, rules_dir: Path, compiled_rules_dir: Path | None) -> bytes | None:
149
+ """
150
+ Compile rules on remote server
151
+ :params rules_dir path with rules
152
+ """
153
+
154
+ rules_dir = rules_dir.expanduser().resolve()
155
+ if not rules_dir.is_dir():
156
+ console.error("Invalid rules directory: {rules_dir}")
157
+ sys.exit(1)
158
+
159
+ # always take new version of taxonomy
160
+ shutil.copytree(rules_dir.parent / "taxonomy", rules_dir / "taxonomy", dirs_exist_ok=True)
161
+
162
+ compiled_rules: bytes | None = None
163
+ try:
164
+ # prepare folder
165
+ await self._create_tmp_directory()
166
+
167
+ # upload rules for compilation
168
+ await self._upload_rules(rules_dir)
169
+
170
+ await self._compile_rules_on_server()
171
+ compiled_rules = await self._download_compiled_rules()
172
+ finally:
173
+ # cleanup
174
+ await self._cleanup(rules_dir)
175
+
176
+ return compiled_rules
177
+
178
+ async def test_rules(self, root_rules_dir: Path, container_rules_dir: Path) -> bool:
179
+ # always take new version of taxonomy
180
+ shutil.copytree(root_rules_dir.parent / "taxonomy", root_rules_dir / "taxonomy", dirs_exist_ok=True)
181
+
182
+ try:
183
+ # prepare folder
184
+ await self._create_tmp_directory()
185
+
186
+ # upload rules for compilation
187
+ await self._upload_rules(root_rules_dir)
188
+
189
+ await self._compile_rules_on_server()
190
+
191
+ # generate random container name for avoid collision with several peoples
192
+ process = await self._run_stream_command(
193
+ f'ctr run --rm --memory-limit=1000000000 \
194
+ --mount type=bind,src={self.tmp_directory / "compiled-rules"},dst=/compiled-rules,options=rbind:rw \
195
+ --mount type=bind,src={self.tmp_directory / "rules" / container_rules_dir},dst=/rules,options=rbind:rw \
196
+ "{settings.docker.path}:{settings.docker.image_tag}" {self._generate_random_string()} package-builder correlation:test -r /rules -c /compiled-rules',
197
+ is_sudo=True,
198
+ )
199
+ finally:
200
+ # cleanup
201
+ await self._cleanup(root_rules_dir)
202
+
203
+ return process.exit_status == 0
@@ -0,0 +1,178 @@
1
+ import asyncio
2
+ from collections.abc import Coroutine
3
+ from io import BytesIO
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ import aiofiles
9
+ import zstandard
10
+ from ptsandbox import Sandbox
11
+ from ptsandbox.models import (
12
+ ArtifactType,
13
+ LogType,
14
+ SandboxBaseTaskResponse,
15
+ SandboxFileNotFoundException,
16
+ )
17
+ from rich.markup import escape
18
+ from rich.progress import Progress, TaskID
19
+
20
+ from sandbox_cli.console import console
21
+
22
+ semaphore = asyncio.Semaphore(value=12)
23
+
24
+
25
+ async def _save_artifact(
26
+ scan_id: UUID,
27
+ sandbox: Sandbox,
28
+ path: Path,
29
+ file_uri: str,
30
+ decompress: bool = False,
31
+ progress: Progress | None = None,
32
+ idx: str | None = None,
33
+ image: str | None = None,
34
+ link: str | None = None,
35
+ ) -> None:
36
+ _, uri = file_uri.split(":")
37
+ if not uri:
38
+ return
39
+
40
+ # sanitize path
41
+ path = Path(str(path).replace(" ", "_"))
42
+ task_id: TaskID = None # type: ignore
43
+
44
+ async with semaphore:
45
+ if progress:
46
+ if idx and image and link:
47
+ task_id = progress.add_task(
48
+ description=f"Download [green]{escape(path.name)}[/]",
49
+ idx=idx,
50
+ image=image,
51
+ url=link,
52
+ )
53
+ else:
54
+ task_id = progress.add_task(rf"\[[green1]{scan_id}[/]] {escape(path.name)}")
55
+
56
+ path.parent.mkdir(exist_ok=True, parents=True)
57
+ try:
58
+ downloaded_data = await sandbox.get_file(uri)
59
+ except SandboxFileNotFoundException:
60
+ console.warning(f"File {path.name} not found in storage: {uri=} {scan_id=}")
61
+ if progress:
62
+ progress.stop_task(task_id)
63
+ progress.update(task_id=task_id, visible=False)
64
+ return
65
+
66
+ if decompress:
67
+ try:
68
+ dctx = zstandard.ZstdDecompressor()
69
+ with open(path, "wb") as output:
70
+ input_fd = BytesIO(downloaded_data)
71
+ dctx.copy_stream(input_fd, output)
72
+ except zstandard.ZstdError as e:
73
+ console.warning(f"Can't decompress [yellow]{path.name}[/]. {e}")
74
+ else:
75
+ async with aiofiles.open(path, "wb") as fd:
76
+ await fd.write(downloaded_data)
77
+
78
+ if progress:
79
+ progress.stop_task(task_id)
80
+ progress.update(task_id=task_id, visible=False)
81
+
82
+
83
+ async def download(
84
+ report: SandboxBaseTaskResponse.LongReport,
85
+ sandbox: Sandbox,
86
+ out_dir: Path,
87
+ all: bool = False,
88
+ artifacts: bool = False,
89
+ crashdumps: bool = False,
90
+ debug: bool = False,
91
+ decompress: bool = False,
92
+ files: bool = False,
93
+ logs: bool = False,
94
+ procdumps: bool = False,
95
+ video: bool = False,
96
+ progress: Progress | None = None,
97
+ idx: str | None = None,
98
+ image: str | None = None,
99
+ link: str | None = None,
100
+ ) -> None:
101
+ tasks: list[Coroutine[Any, Any, None]] = []
102
+
103
+ def add_task(path: Path, file_uri: str, decompress: bool = False) -> None:
104
+ tasks.append(
105
+ _save_artifact(
106
+ scan_id=report.scan_id,
107
+ sandbox=sandbox,
108
+ path=path,
109
+ file_uri=file_uri,
110
+ progress=progress,
111
+ idx=idx,
112
+ image=image,
113
+ link=link,
114
+ decompress=decompress,
115
+ )
116
+ )
117
+
118
+ for artifact in report.artifacts:
119
+ sandbox_result = artifact.find_sandbox_result()
120
+ if sandbox_result is None:
121
+ continue
122
+
123
+ if sandbox_result.details is None:
124
+ continue
125
+
126
+ if sandbox_result.details.sandbox is None:
127
+ continue
128
+
129
+ for log in sandbox_result.details.sandbox.logs:
130
+ # download default logs by default
131
+ if (all or logs) and log.type in {
132
+ LogType.EVENT_CORRELATED,
133
+ LogType.EVENT_NORMALIZED,
134
+ LogType.EVENT_RAW,
135
+ LogType.NETWORK,
136
+ }:
137
+ add_task(out_dir / log.file_name, log.file_uri)
138
+
139
+ if (all or video) and log.type == LogType.SCREENSHOT:
140
+ add_task(out_dir / log.file_name, log.file_uri)
141
+
142
+ if (all or crashdumps) and log.file_name in {"crashdump.bin", "crashdump.metadata"}:
143
+ add_task(out_dir / "crashdumps" / log.file_name, log.file_uri)
144
+
145
+ if (all or debug) and log.type in {LogType.DEBUG, LogType.GRAPH}:
146
+ add_task(out_dir / "debug" / log.file_name, log.file_uri)
147
+
148
+ if artifacts or files or procdumps or all:
149
+ if not sandbox_result.details:
150
+ continue
151
+
152
+ if not sandbox_result.details.sandbox:
153
+ continue
154
+
155
+ if not sandbox_result.details.sandbox.artifacts:
156
+ continue
157
+
158
+ for artifact in sandbox_result.details.sandbox.artifacts:
159
+ if not artifact.file_info:
160
+ continue
161
+
162
+ if artifact.type == ArtifactType.FILE and (files or artifacts or all):
163
+ add_task(
164
+ out_dir / "artifacts" / artifact.file_info.file_path.removeprefix("/"),
165
+ artifact.file_info.file_uri,
166
+ )
167
+ if artifact.type == ArtifactType.PROCESS_DUMP and (procdumps or artifacts or all):
168
+ add_task(
169
+ out_dir
170
+ / "process_dump"
171
+ / artifact.file_info.details.process_dump.process_name.removeprefix("/"), # type: ignore
172
+ artifact.file_info.file_uri,
173
+ decompress=decompress,
174
+ )
175
+ if not tasks:
176
+ console.info(f"Nothing to download from {report.scan_id}")
177
+
178
+ await asyncio.gather(*tasks)
@@ -0,0 +1,66 @@
1
+ import orjson
2
+ from ptsandbox.models import ArtifactType, EngineSubsystem, SandboxBaseTaskResponse
3
+
4
+ from sandbox_cli.models.detections import Detections
5
+
6
+
7
+ def extract_verdict_from_trace(trace: bytes) -> set[str]:
8
+ non_generics: set[str] = set()
9
+ generics: set[str] = set()
10
+
11
+ d = Detections(trace)
12
+ for detect in d.malware:
13
+ if detect.name.split(".")[-2] == "Generic":
14
+ generics.add(detect.name)
15
+ else:
16
+ non_generics.add(detect.name)
17
+
18
+ return non_generics if len(non_generics) != 0 else generics
19
+
20
+
21
+ def extract_network_from_trace(trace: bytes) -> set[str]:
22
+ detects: set[str] = set()
23
+
24
+ for line in trace.decode().splitlines(keepends=False):
25
+ event = orjson.loads(line)
26
+ if event.get("event.name") == "Auxiliary.ObtainNetworkAlert":
27
+ detects.add(event.get("s_msg"))
28
+
29
+ return detects
30
+
31
+
32
+ def extract_static(report: SandboxBaseTaskResponse.LongReport) -> list[str]:
33
+ sandbox_result = report.artifacts[0].find_sandbox_result()
34
+
35
+ ret: set[str] = set()
36
+
37
+ if sandbox_result:
38
+ for artifact in report.artifacts:
39
+ if artifact.type == ArtifactType.PROCESS_DUMP:
40
+ continue
41
+
42
+ if artifact.engine_results is None:
43
+ continue
44
+
45
+ for artifact_result in artifact.engine_results:
46
+ if artifact_result.engine_subsystem not in {EngineSubsystem.STATIC, EngineSubsystem.AV}:
47
+ continue
48
+
49
+ ret |= {f"{artifact_result.engine_code_name}: {x.detect}" for x in artifact_result.detections}
50
+
51
+ return sorted(ret)
52
+
53
+
54
+ def extract_memory(report: SandboxBaseTaskResponse.LongReport) -> set[str]:
55
+ ret: set[str] = set()
56
+
57
+ sandbox_result = report.artifacts[0].find_sandbox_result()
58
+
59
+ if sandbox_result:
60
+ for artifact in sandbox_result.details.sandbox.artifacts: # type: ignore
61
+ if artifact.type == ArtifactType.PROCESS_DUMP:
62
+ artifact_result = artifact.find_static_result()
63
+ if artifact_result:
64
+ ret |= {x.detect for x in artifact_result.detections}
65
+
66
+ return ret
@@ -0,0 +1,84 @@
1
+ from pathlib import Path, PurePath
2
+
3
+ slow_dll_methods = {
4
+ "RtlAllocateHeap",
5
+ "GetModuleFileNameA",
6
+ "GetModuleFileNameW",
7
+ }
8
+
9
+
10
+ def __validate_hook(hook: str) -> None:
11
+ args = hook.split(",")
12
+
13
+ if args[0] in ("System32", "SysWOW64"):
14
+ args = args[1:]
15
+
16
+ if len(args) < 1 or not args[0]:
17
+ raise RuntimeError("Empty function name")
18
+
19
+ func_name = args[0]
20
+ if func_name in slow_dll_methods:
21
+ raise RuntimeError(f'Function "{func_name}" considered slow')
22
+
23
+ if len(args) < 2 or not args[1]:
24
+ raise RuntimeError("Empty strategy")
25
+
26
+ strategy: str
27
+ function_args: list[str]
28
+
29
+ if args[1] == "no-retval":
30
+ strategy = args[2]
31
+ function_args = args[3:]
32
+ else:
33
+ strategy = args[1]
34
+ function_args = args[2:]
35
+
36
+ if strategy not in ("log", "log+stack"):
37
+ raise RuntimeError("Bad strategy")
38
+
39
+ for arg in function_args:
40
+ if not arg:
41
+ raise RuntimeError(f"Extra comma in function argument list of {func_name}")
42
+
43
+
44
+ def __merge_dll_hooks(
45
+ dll_hooks_file: Path,
46
+ name: PurePath,
47
+ dll_hooks_cache: set[str],
48
+ ) -> bytes:
49
+ file_hooks = b""
50
+ with dll_hooks_file.open() as file:
51
+ for hook in file:
52
+ hook = hook.strip()
53
+ if hook == "" or hook.startswith("#"):
54
+ continue
55
+
56
+ __validate_hook(hook)
57
+ prefix = ""
58
+ if hook.startswith("SysWOW64"):
59
+ prefix = "SysWOW64\\"
60
+ elif hook.startswith("System32"):
61
+ prefix = "System32\\"
62
+
63
+ hook = f"{prefix}{name},{hook}"
64
+
65
+ if hook in dll_hooks_cache:
66
+ continue
67
+
68
+ file_hooks += f"{hook}\n".encode()
69
+ dll_hooks_cache.add(hook)
70
+ return file_hooks
71
+
72
+
73
+ def merge_dll_hooks(dll_hooks_dir: Path) -> bytes:
74
+ dll_hooks_cache: set[str] = set()
75
+
76
+ merged_hooks = b""
77
+ for hooks_file in dll_hooks_dir.rglob("*.txt"):
78
+ name = PurePath(hooks_file.relative_to(dll_hooks_dir)).with_suffix(".dll")
79
+ merged_hooks += __merge_dll_hooks(hooks_file, name, dll_hooks_cache)
80
+
81
+ if not merged_hooks:
82
+ raise RuntimeError("No dll hooks has been found")
83
+
84
+ return merged_hooks