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.
- sandbox_cli/__main__.py +28 -0
- sandbox_cli/cli/__init__.py +46 -0
- sandbox_cli/cli/downloader.py +308 -0
- sandbox_cli/cli/images.py +45 -0
- sandbox_cli/cli/reporter.py +146 -0
- sandbox_cli/cli/rules/__init__.py +83 -0
- sandbox_cli/cli/scanner/__init__.py +681 -0
- sandbox_cli/cli/unpack.py +50 -0
- sandbox_cli/console.py +24 -0
- sandbox_cli/internal/__init__.py +0 -0
- sandbox_cli/internal/config.py +143 -0
- sandbox_cli/internal/helpers.py +32 -0
- sandbox_cli/models/__init__.py +0 -0
- sandbox_cli/models/detections.py +91 -0
- sandbox_cli/utils/__init__.py +0 -0
- sandbox_cli/utils/compiler/__init__.py +60 -0
- sandbox_cli/utils/compiler/abc.py +51 -0
- sandbox_cli/utils/compiler/docker.py +136 -0
- sandbox_cli/utils/compiler/ssh.py +203 -0
- sandbox_cli/utils/downloader/__init__.py +178 -0
- sandbox_cli/utils/extractors.py +66 -0
- sandbox_cli/utils/merge_dll_hooks.py +84 -0
- sandbox_cli/utils/scanner/__init__.py +310 -0
- sandbox_cli/utils/scanner/advanced.py +360 -0
- sandbox_cli/utils/scanner/rescan.py +258 -0
- sandbox_cli/utils/unpack/__init__.py +96 -0
- sandbox_cli/utils/unpack/plugins/__init__.py +0 -0
- sandbox_cli/utils/unpack/plugins/abc.py +18 -0
- sandbox_cli/utils/unpack/plugins/correlation.py +52 -0
- sandbox_cli/utils/unpack/plugins/sort_by_plugins.py +30 -0
- sandbox_cli-0.2.26.dist-info/METADATA +139 -0
- sandbox_cli-0.2.26.dist-info/RECORD +36 -0
- sandbox_cli-0.2.26.dist-info/WHEEL +4 -0
- sandbox_cli-0.2.26.dist-info/entry_points.txt +2 -0
- sandbox_cli-0.2.26.dist-info/licenses/LICENSE +21 -0
- 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
|