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,50 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ from cyclopts import Parameter, validators
5
+
6
+ from sandbox_cli.console import console
7
+ from sandbox_cli.utils.unpack import Unpack
8
+
9
+
10
+ def unpack_logs(
11
+ traces: Annotated[
12
+ list[Path],
13
+ Parameter(
14
+ help="The path to the folder with the raw traces or with the sandbox-logs.zip",
15
+ negative="",
16
+ validator=validators.Path(exists=True),
17
+ ),
18
+ ],
19
+ /,
20
+ ) -> None:
21
+ """
22
+ Convert sandbox logs into an analysis-friendly format.
23
+
24
+ Output file structure:
25
+ * drakvuf-trace
26
+ * drakvuf-trace.log
27
+ * correlated
28
+ * events-correlated.log
29
+ * events-correlated.log.<DETECT_NAME>
30
+ * normalized
31
+ * events-normalized.log
32
+ * events-normalized.log.<DETECT_NAME>
33
+ * network
34
+ * tcpdump.pcap
35
+ * raw
36
+ * drakvuf-trace.log.zst
37
+ * tcpdump.pcap
38
+
39
+ Usage examples:
40
+ * Checks for drakvuf-trace.log.gz or drakvuf-trace.log.zst in the current directory:
41
+ _sandbox-cli unpack ._
42
+ * Extracts and processes logs into the sandbox_logs directory:
43
+ _sandbox-cli unpack sandbox_logs.zip_
44
+ * Handles multiple archives simultaneously:
45
+ _sandbox-cli unpack sandbox_logs.zip sandbox_logs_1.zip_
46
+ """
47
+
48
+ for trace in traces:
49
+ console.info(f"Unpacking {trace}")
50
+ Unpack(trace=trace).run()
sandbox_cli/console.py ADDED
@@ -0,0 +1,24 @@
1
+ from rich.console import Console
2
+
3
+
4
+ class SandboxConsole(Console):
5
+ INFO = "[turquoise2 bold][INFO][/]"
6
+ WARNING = "[yellow1 bold][WARN][/]"
7
+ ERROR = "[red3 bold][ERROR][/]"
8
+ DONE = "[green3 bold][DONE][/]"
9
+
10
+ def done(self, message: str) -> None:
11
+ self.print(f"{self.DONE} {message}")
12
+
13
+ def info(self, message: str) -> None:
14
+ self.print(f"{self.INFO} {message}")
15
+
16
+ def warning(self, message: str) -> None:
17
+ self.print(f"{self.WARNING} {message}", style="bold")
18
+
19
+ def error(self, message: str) -> None:
20
+ self.print(f"{self.ERROR} {message}", style="bold")
21
+
22
+
23
+ console = SandboxConsole(color_system="auto", emoji=True)
24
+ console = SandboxConsole(color_system="auto", emoji=True)
File without changes
@@ -0,0 +1,143 @@
1
+ import os
2
+ import sys
3
+ import tomllib
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pydantic
9
+ from ptsandbox import SandboxKey
10
+ from pydantic import BaseModel, Field
11
+
12
+ from sandbox_cli.console import console
13
+
14
+
15
+ class VMImage(str, Enum):
16
+ """
17
+ A list of all known images
18
+
19
+ Please note that not all images are supported or available anymore (left as a legacy)
20
+ """
21
+
22
+ ALTWORKSTATION_X64 = "altworkstation-10-x64"
23
+ ASTRALINUX_SMOLENSK_X64 = "astralinux-smolensk-x64"
24
+ REDOS_8_X64 = "redos-8-x64"
25
+ REDOS_MUROM_X64 = "redos-murom-x64"
26
+ UBUNTU_JAMMY_X64 = "ubuntu-jammy-x64"
27
+
28
+ WIN10_1803_X64 = "win10-1803-x64"
29
+ WIN10_22H2_X64 = "win10-22H2-x64"
30
+ WIN11_23H2_X64 = "win11-23H2-x64"
31
+ WIN7_SP1_X64 = "win7-sp1-x64"
32
+ WIN7_SP1_X64_ICS = "win7-sp1-x64-ics"
33
+ WIN81_UPDATE1_X64 = "win8.1-update1-x64"
34
+ WINSERV2016_1198_X64 = "winserv2016-1198-x64"
35
+ WINSERV2019_1879_X64 = "winserv2019-1879-x64"
36
+
37
+ LINUX = "linux"
38
+ WINDOWS = "windows"
39
+
40
+ def __str__(self) -> str:
41
+ return str(self.value)
42
+
43
+ def __repr__(self) -> str:
44
+ return str(self.value)
45
+
46
+
47
+ class Settings(BaseModel):
48
+ class Docker(BaseModel):
49
+ username: str = ""
50
+ token: str = ""
51
+ registry: str = ""
52
+ image_name: str = Field(default="", alias="image-name")
53
+ image_tag: str = Field(default="", alias="image-tag")
54
+
55
+ @property
56
+ def path(self) -> str:
57
+ return f"{self.registry}/{self.image_name}"
58
+
59
+ class Sandbox(BaseModel):
60
+ class SSH(BaseModel):
61
+ username: str = ""
62
+ password: str = ""
63
+
64
+ name: str = ""
65
+ key: str = ""
66
+ host: str = ""
67
+ max_workers: int = Field(default=8, alias="max-workers")
68
+ description: str = ""
69
+
70
+ ssh: SSH = SSH()
71
+
72
+ @property
73
+ def sandbox_key(self) -> SandboxKey:
74
+ return SandboxKey(
75
+ name=self.name,
76
+ key=self.key,
77
+ host=self.host,
78
+ max_workers=self.max_workers,
79
+ description=self.description,
80
+ )
81
+
82
+ # default settings (not changable)
83
+ linux_images: set[VMImage] = {
84
+ VMImage.ALTWORKSTATION_X64,
85
+ VMImage.ASTRALINUX_SMOLENSK_X64,
86
+ VMImage.REDOS_8_X64,
87
+ VMImage.REDOS_MUROM_X64,
88
+ VMImage.UBUNTU_JAMMY_X64,
89
+ }
90
+
91
+ windows_images: set[VMImage] = {
92
+ VMImage.WIN10_1803_X64,
93
+ VMImage.WIN10_22H2_X64,
94
+ VMImage.WIN11_23H2_X64,
95
+ VMImage.WIN7_SP1_X64,
96
+ VMImage.WIN7_SP1_X64_ICS,
97
+ VMImage.WIN81_UPDATE1_X64,
98
+ VMImage.WINSERV2016_1198_X64,
99
+ VMImage.WINSERV2019_1879_X64,
100
+ }
101
+
102
+ rules_archive_name_zip: str = "rules.zip"
103
+ rules_archive_name_gz: str = "rules.tar.gz"
104
+
105
+ report_name: str = "report.json"
106
+ default_image: VMImage = VMImage.WIN10_22H2_X64
107
+ default_duration: int = 300
108
+
109
+ # post init params
110
+ sandbox_keys: list[SandboxKey] = []
111
+
112
+ # configurable parameters
113
+ passwords: list[str] = ["infected", "311138", "password", "12345678", "P@ssw0rd!"]
114
+ docker: Docker = Docker()
115
+ sandbox: list[Sandbox] = []
116
+
117
+ def model_post_init(self, __context: Any) -> None:
118
+ self.sandbox_keys = [x.sandbox_key for x in self.sandbox]
119
+
120
+
121
+ def load_config(path: Path) -> Settings:
122
+ if not path.exists():
123
+ return Settings()
124
+
125
+ with open(path, "rb") as fd:
126
+ raw = tomllib.load(fd)
127
+
128
+ try:
129
+ settings = Settings.model_validate(raw)
130
+ except pydantic.ValidationError as e:
131
+ console.print(f"invalid config at {path}: {e}", style="bold red")
132
+ sys.exit(1)
133
+
134
+ return settings
135
+
136
+
137
+ configpath = os.path.join(
138
+ os.environ.get("APPDATA") or os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.environ["HOME"], ".config"),
139
+ "sandbox-cli",
140
+ "config.toml",
141
+ )
142
+
143
+ settings = load_config(Path(configpath))
@@ -0,0 +1,32 @@
1
+ import sys
2
+ from typing import Any
3
+
4
+ from ptsandbox import SandboxKey
5
+
6
+ from sandbox_cli.console import console
7
+ from sandbox_cli.internal.config import settings
8
+
9
+
10
+ def get_key_by_name(key_name: str) -> SandboxKey:
11
+ for sandbox_key in settings.sandbox_keys:
12
+ if sandbox_key.name == key_name:
13
+ return sandbox_key
14
+ raise KeyError()
15
+
16
+
17
+ def get_sandbox_key_by_host(task_host: str) -> SandboxKey:
18
+ for sandbox_key in settings.sandbox_keys:
19
+ if sandbox_key.host == task_host:
20
+ return sandbox_key
21
+
22
+ raise KeyError()
23
+
24
+
25
+ def validate_key(_: Any, value: Any) -> None:
26
+ try:
27
+ get_key_by_name(value)
28
+ except KeyError:
29
+ console.error(
30
+ f'Key "{value}" doesn\'t exists in config. Available keys: "{'","'.join(x.name for x in settings.sandbox_keys)}"'
31
+ )
32
+ sys.exit(1)
File without changes
@@ -0,0 +1,91 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+
6
+ class DetectionType(str, Enum):
7
+ """
8
+ Enum with sandbox detections levels
9
+ """
10
+
11
+ silent = "silent"
12
+ suspicious = "suspicious"
13
+ malware = "malware"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Detect:
18
+ """
19
+ Dataclass for sandbox detect, comparable by key (weight ignored)
20
+ """
21
+
22
+ name: str
23
+ weight: int | None
24
+
25
+ def __key(self) -> tuple[str]:
26
+ return (self.name,)
27
+
28
+ def __hash__(self) -> int:
29
+ return hash(self.__key())
30
+
31
+ def __eq__(self, other: object) -> bool:
32
+ if isinstance(other, Detect):
33
+ return self.__key() == other.__key()
34
+ return NotImplemented
35
+
36
+
37
+ class Detections:
38
+ """
39
+ Class for stroing/parsing detections from correlated-logs
40
+ """
41
+
42
+ detections: dict[DetectionType, set[Detect]]
43
+
44
+ _real_name: str
45
+
46
+ def __init__(self, trace: bytes) -> None:
47
+ """
48
+ trace: UN-gzipped correlated-logs bytes.
49
+ """
50
+
51
+ self.detections = {
52
+ DetectionType.silent: set(),
53
+ DetectionType.suspicious: set(),
54
+ DetectionType.malware: set(),
55
+ }
56
+
57
+ for line in trace.decode().splitlines(keepends=False):
58
+ event = json.loads(line)
59
+ if event.get("auxiliary.type", None) == "init":
60
+ self._real_name = event.get("object.name")
61
+ detect_type = event.get("detect.type")
62
+ if detect_type in DetectionType.__members__.keys():
63
+ self.detections[DetectionType[detect_type]].add(
64
+ Detect(
65
+ name=event.get("detect.name"),
66
+ weight=event.get("weight", None),
67
+ )
68
+ )
69
+
70
+ def __repr__(self) -> str:
71
+ return repr(self.detections)
72
+
73
+ @property
74
+ def silent(self) -> set[Detect]:
75
+ """Only silent detects"""
76
+ return self.detections[DetectionType.silent]
77
+
78
+ @property
79
+ def suspicious(self) -> set[Detect]:
80
+ """Only suspicious detects"""
81
+ return self.detections[DetectionType.suspicious]
82
+
83
+ @property
84
+ def malware(self) -> set[Detect]:
85
+ """Only malware detects"""
86
+ return self.detections[DetectionType.malware]
87
+
88
+ @property
89
+ def real_name(self) -> str:
90
+ """Real sample name, extracted from 'init' auxiliary event"""
91
+ return self._real_name
File without changes
@@ -0,0 +1,60 @@
1
+ import sys
2
+ from functools import lru_cache
3
+ from pathlib import Path
4
+
5
+ from sandbox_cli.console import console
6
+ from sandbox_cli.internal.config import settings
7
+ from sandbox_cli.utils.compiler.abc import AbstractCompiler
8
+ from sandbox_cli.utils.compiler.docker import DockerCompiler
9
+ from sandbox_cli.utils.compiler.ssh import RemoteCompiler
10
+
11
+ default_out_dir: str = "compiled-rules.local.tmp"
12
+
13
+
14
+ @lru_cache
15
+ def get_compiler(*, is_local: bool) -> AbstractCompiler:
16
+ if is_local:
17
+ if not settings.docker.token and not settings.docker.registry:
18
+ console.warning("If you want use local docker container specify options in config")
19
+ sys.exit(1)
20
+
21
+ return DockerCompiler()
22
+ return RemoteCompiler()
23
+
24
+
25
+ async def compile_rules_internal(*, rules_dir: Path, is_local: bool, compiled_rules_dir: Path | None = None) -> bytes:
26
+ compiler = get_compiler(is_local=is_local)
27
+
28
+ if rules := await compiler.compile_rules(rules_dir, compiled_rules_dir):
29
+ return rules
30
+
31
+ console.error("Bad rules")
32
+ sys.exit(1)
33
+
34
+
35
+ async def test_rules_internal(*, rules: Path, is_local: bool) -> None:
36
+ compiler = get_compiler(is_local=is_local)
37
+
38
+ root_rules_dir: Path | None = None
39
+
40
+ # maybe bug or feature
41
+ # don't scan folder ~/rules/<platform>/correlation to avoid stupidly long testing
42
+ for parent in rules.parents:
43
+ if parent.name in {"correlation", "normalization"}:
44
+ root_rules_dir = parent
45
+ break
46
+
47
+ if not root_rules_dir:
48
+ console.error(f"Invalid rule path (read help): {rules}")
49
+ sys.exit(1)
50
+
51
+ # Generate special path for remote container
52
+ container_rules_dir = root_rules_dir.name / rules.relative_to(root_rules_dir)
53
+
54
+ # after extracting special path take path without correlation/normalization suffix
55
+ root_rules_dir = root_rules_dir.parent
56
+
57
+ if await compiler.test_rules(root_rules_dir, container_rules_dir):
58
+ console.info("Rules fine")
59
+ else:
60
+ console.error("Bad rules")
@@ -0,0 +1,51 @@
1
+ import io
2
+ import sys
3
+ import tarfile
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+
7
+ from sandbox_cli.console import console
8
+
9
+
10
+ class AbstractCompiler(ABC):
11
+ @abstractmethod
12
+ async def pull_image(self) -> None:
13
+ pass
14
+
15
+ @abstractmethod
16
+ async def compile_rules(self, rules_dir: Path, compiled_rules_dir: Path | None) -> bytes | None:
17
+ pass
18
+
19
+ @abstractmethod
20
+ async def test_rules(self, root_rules_dir: Path, container_rules_dir: Path) -> bool:
21
+ pass
22
+
23
+ def normalize_paths(self, rules_dir: Path, compiled_rules_dir: Path) -> tuple[Path, Path]:
24
+ """
25
+ Check and normalize rules_dir and compiled_rules_dir
26
+ """
27
+
28
+ rules_dir = rules_dir.expanduser().resolve()
29
+ if not rules_dir.is_dir():
30
+ console.error(f"Invalid directory with raw rules: {rules_dir}, {rules_dir.is_dir()}")
31
+ sys.exit(1)
32
+
33
+ compiled_rules_dir = compiled_rules_dir.expanduser()
34
+ compiled_rules_dir.mkdir(exist_ok=True)
35
+ compiled_rules_dir = compiled_rules_dir.resolve() # until directory created, we can't resolve it
36
+
37
+ return rules_dir, compiled_rules_dir
38
+
39
+ def compress_rules(self, compiled_rules: Path) -> bytes:
40
+ compiled_rules = compiled_rules.expanduser().resolve()
41
+
42
+ fake_file = io.BytesIO()
43
+
44
+ with tarfile.open(mode="w:gz", fileobj=fake_file) as tar:
45
+ for file_name in [
46
+ "event_correlation_graph.json",
47
+ "event_normalization_graph.json",
48
+ ]: # compiled_rules.glob("*.json")
49
+ tar.add(compiled_rules / file_name, arcname=file_name)
50
+
51
+ return fake_file.getvalue()
@@ -0,0 +1,136 @@
1
+ import shutil
2
+ import sys
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import requests.exceptions
7
+ from docker import DockerClient, from_env
8
+ from docker.errors import APIError, DockerException, ImageNotFound
9
+ from docker.models.containers import Container
10
+ from rich.markup import escape
11
+
12
+ from sandbox_cli.console import console
13
+ from sandbox_cli.internal.config import settings
14
+ from sandbox_cli.utils.compiler.abc import AbstractCompiler
15
+
16
+
17
+ class DockerCompiler(AbstractCompiler):
18
+ client: DockerClient
19
+
20
+ def __init__(self) -> None:
21
+ super().__init__()
22
+ try:
23
+ self.client = from_env()
24
+ except DockerException as e:
25
+ console.error(f"Can't connect to docker: {e}")
26
+ sys.exit(1)
27
+
28
+ async def pull_image(self) -> None:
29
+ """
30
+ When image not found start pulling from remote
31
+ """
32
+
33
+ console.warning("Docker image not found, start pulling, be patient")
34
+ self.client.api.login(
35
+ username=settings.docker.username,
36
+ password=settings.docker.token,
37
+ registry=settings.docker.registry,
38
+ )
39
+
40
+ image = self.client.images.pull(repository=settings.docker.path, tag=settings.docker.image_tag)
41
+ console.info(f"Docker image successfully pulled: {image}")
42
+
43
+ async def run_docker(self, command: str, name: str, rules_dir: Path, compiled_rules_dir: Path) -> bool:
44
+ """
45
+ Start docker container with given command
46
+ :return True - if command was successfull, False - if some bad stuff happens
47
+ """
48
+
49
+ try:
50
+ self.client.images.get(f"{settings.docker.path}:{settings.docker.image_tag}")
51
+ except ImageNotFound:
52
+ await self.pull_image()
53
+
54
+ # need copy taxonomy in rules dir otherwise compiler failed
55
+ local_taxonomy = rules_dir / "taxonomy"
56
+ shutil.copytree(rules_dir.parent / "taxonomy", local_taxonomy)
57
+
58
+ container: Container | None = None
59
+ exit_data: dict[str, str | int] = {}
60
+ try:
61
+ container = self.client.containers.run(
62
+ image=f"{settings.docker.path}:{settings.docker.image_tag}",
63
+ command=command,
64
+ name=name,
65
+ detach=True,
66
+ mem_limit="2g",
67
+ volumes={
68
+ str(rules_dir): {"bind": "/rules", "mode": "ro"},
69
+ str(compiled_rules_dir): {"bind": "/compiled-rules", "mode": "rw"},
70
+ },
71
+ )
72
+
73
+ assert container is not None
74
+
75
+ logs = container.logs(stream=True, follow=True)
76
+ for log in logs:
77
+ console.print(escape(log.decode()), end="")
78
+ except (KeyboardInterrupt, Exception) as e: # pylint: disable=broad-exception-caught
79
+ console.error(f"Exception while running docker: {e}")
80
+ finally:
81
+ if local_taxonomy.is_dir():
82
+ shutil.rmtree(local_taxonomy)
83
+
84
+ if container:
85
+ try:
86
+ exit_data = container.wait(timeout=1) # type: ignore
87
+ except requests.exceptions.ReadTimeout:
88
+ exit_data = {"Error": "some shit happened", "StatusCode": -1}
89
+
90
+ try:
91
+ container.remove(force=True)
92
+ except APIError as e:
93
+ console.error(f"Can't remove docker image: {e} {exit_data}")
94
+
95
+ if exit_data.get("StatusCode") != 0 or exit_data.get("Error"):
96
+ return False
97
+
98
+ return True
99
+
100
+ async def compile_rules(self, rules_dir: Path, compiled_rules_dir: Path | None = None) -> bytes | None:
101
+ with tempfile.TemporaryDirectory("sandbox-cli-docker") as tmp_dir:
102
+ if not compiled_rules_dir:
103
+ compiled_rules_dir = Path(tmp_dir)
104
+
105
+ rules_dir, compiled_rules_dir = self.normalize_paths(rules_dir, compiled_rules_dir)
106
+
107
+ status = await self.run_docker(
108
+ command=(
109
+ "bash -c 'cp -r /rules /rules.copy && "
110
+ "package-builder correlation:compile -r /rules.copy -c /compiled-rules'"
111
+ ),
112
+ name="drakvuf-rules-compile",
113
+ rules_dir=rules_dir,
114
+ compiled_rules_dir=compiled_rules_dir,
115
+ )
116
+
117
+ if not status:
118
+ return None
119
+
120
+ return self.compress_rules(compiled_rules_dir)
121
+
122
+ async def test_rules(self, root_rules_dir: Path, container_rules_dir: Path) -> bool:
123
+ # create tmp folder for store compiled rules
124
+ with tempfile.TemporaryDirectory("sandbox-cli-docker") as tmp:
125
+ # ignore result for tests
126
+ await self.compile_rules(root_rules_dir, Path(tmp))
127
+
128
+ # run tests
129
+ status = await self.run_docker(
130
+ command=f"package-builder correlation:test -r {Path('/rules') / container_rules_dir} -c /compiled-rules",
131
+ name="drakvuf-rules-test",
132
+ rules_dir=root_rules_dir,
133
+ compiled_rules_dir=Path(tmp),
134
+ )
135
+
136
+ return status