chaintrace 0.1.0__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.
- chaintrace/__init__.py +3 -0
- chaintrace/__main__.py +4 -0
- chaintrace/baseline.py +81 -0
- chaintrace/cli.py +159 -0
- chaintrace/clients.json +69 -0
- chaintrace/clients.py +106 -0
- chaintrace/models.py +62 -0
- chaintrace/net.py +179 -0
- chaintrace/probe.py +71 -0
- chaintrace/procfs.py +217 -0
- chaintrace/redact.py +44 -0
- chaintrace/render.py +117 -0
- chaintrace/rules.py +146 -0
- chaintrace/trace.py +118 -0
- chaintrace-0.1.0.dist-info/METADATA +139 -0
- chaintrace-0.1.0.dist-info/RECORD +20 -0
- chaintrace-0.1.0.dist-info/WHEEL +5 -0
- chaintrace-0.1.0.dist-info/entry_points.txt +2 -0
- chaintrace-0.1.0.dist-info/licenses/LICENSE +21 -0
- chaintrace-0.1.0.dist-info/top_level.txt +1 -0
chaintrace/probe.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""A short read-only bpftrace program for one pid."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
_TEMPLATE = """// Read-only trace for one process. Prints exec, connect, open, and chmod.
|
|
9
|
+
// IPv4 connections only. IPv6 peers show up in `chaintrace status`.
|
|
10
|
+
|
|
11
|
+
BEGIN
|
|
12
|
+
{
|
|
13
|
+
printf("chaintrace probe\\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
tracepoint:syscalls:sys_enter_execve
|
|
17
|
+
/pid == __PID__/
|
|
18
|
+
{
|
|
19
|
+
printf("exec %d %s %s\\n", pid, comm, str(args->filename));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
tracepoint:syscalls:sys_enter_connect
|
|
23
|
+
/pid == __PID__/
|
|
24
|
+
{
|
|
25
|
+
$sa = args->uservaddr;
|
|
26
|
+
if ($sa->sa_family == 2) {
|
|
27
|
+
$port = ($sa->sa_data[0] << 8) | $sa->sa_data[1];
|
|
28
|
+
$v4 = (struct sockaddr_in *)args->uservaddr;
|
|
29
|
+
printf("connect %d %s %s %d\\n", pid, comm, ntop($v4->sin_addr.s_addr), $port);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
tracepoint:syscalls:sys_enter_openat
|
|
34
|
+
/pid == __PID__/
|
|
35
|
+
{
|
|
36
|
+
printf("open %d %s %s\\n", pid, comm, str(args->filename));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
tracepoint:syscalls:sys_enter_fchmodat
|
|
40
|
+
/pid == __PID__/
|
|
41
|
+
{
|
|
42
|
+
printf("chmod %d %s %s %d\\n", pid, comm, str(args->filename), args->mode);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interval:s:__SECONDS__
|
|
46
|
+
{
|
|
47
|
+
exit();
|
|
48
|
+
}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_probe(pid: int, seconds: int) -> str:
|
|
53
|
+
if pid <= 0:
|
|
54
|
+
raise ValueError("pid must be positive")
|
|
55
|
+
if seconds < 1 or seconds > 300:
|
|
56
|
+
raise ValueError("seconds must be from 1 to 300")
|
|
57
|
+
return _TEMPLATE.replace("__PID__", str(pid)).replace("__SECONDS__", str(seconds))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def unavailable(seconds_ok: bool = True) -> str | None:
|
|
61
|
+
if shutil.which("bpftrace") is None:
|
|
62
|
+
return "bpftrace is not installed"
|
|
63
|
+
try:
|
|
64
|
+
euid = os.geteuid()
|
|
65
|
+
except AttributeError:
|
|
66
|
+
return "bpftrace needs Linux"
|
|
67
|
+
if euid != 0:
|
|
68
|
+
return "bpftrace needs root"
|
|
69
|
+
if not seconds_ok:
|
|
70
|
+
return "seconds must be from 1 to 300"
|
|
71
|
+
return None
|
chaintrace/procfs.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Read a /proc-shaped directory. File contents are never opened."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from chaintrace.clients import recognize
|
|
9
|
+
from chaintrace.models import Child, Connection, Observation, OpenFile
|
|
10
|
+
from chaintrace.net import RawSocket, connection_from_raw, parse_proc_net
|
|
11
|
+
from chaintrace.redact import redact_argv
|
|
12
|
+
|
|
13
|
+
_TABLES = ("tcp", "tcp6", "udp", "udp6")
|
|
14
|
+
_STATE_WORDS = {
|
|
15
|
+
"R": "running",
|
|
16
|
+
"S": "sleeping",
|
|
17
|
+
"D": "disk",
|
|
18
|
+
"Z": "zombie",
|
|
19
|
+
"T": "stopped",
|
|
20
|
+
"t": "tracing",
|
|
21
|
+
"I": "idle",
|
|
22
|
+
"X": "dead",
|
|
23
|
+
}
|
|
24
|
+
_SENSITIVE = (
|
|
25
|
+
"jwtsecret",
|
|
26
|
+
"jwt.hex",
|
|
27
|
+
"keystore",
|
|
28
|
+
"utc--",
|
|
29
|
+
"nodekey",
|
|
30
|
+
"validator_keys",
|
|
31
|
+
"mnemonic",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProcError(ValueError):
|
|
36
|
+
"""The process table could not be read."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_sensitive(path: str) -> bool:
|
|
40
|
+
lowered = path.lower()
|
|
41
|
+
return any(marker in lowered for marker in _SENSITIVE)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_pid(proc_root: Path, pid: int) -> Observation:
|
|
45
|
+
root = Path(proc_root)
|
|
46
|
+
base = root / str(pid)
|
|
47
|
+
if not base.is_dir():
|
|
48
|
+
raise ProcError(f"pid {pid} is not in {root}")
|
|
49
|
+
comm = _read_comm(base)
|
|
50
|
+
exe = _read_exe(base)
|
|
51
|
+
cmdline = redact_argv(_read_cmdline(base))
|
|
52
|
+
letter, ppid, utime, stime, rss_pages = _read_stat(base / "stat")
|
|
53
|
+
client = recognize(comm, exe)
|
|
54
|
+
files = _open_files(base, comm)
|
|
55
|
+
inodes = _socket_inodes(base)
|
|
56
|
+
raw = _sockets(root, pid, inodes)
|
|
57
|
+
connections = tuple(connection_from_raw(item, client) for item in raw)
|
|
58
|
+
children = _children(root, pid)
|
|
59
|
+
for child in children:
|
|
60
|
+
child_base = root / str(child.pid)
|
|
61
|
+
files.extend(_open_files(child_base, child.comm))
|
|
62
|
+
files.sort(key=lambda item: (item.path, item.comm))
|
|
63
|
+
ordered = tuple(sorted(connections, key=lambda item: (item.kind, item.local, item.remote)))
|
|
64
|
+
return Observation(
|
|
65
|
+
pid=pid,
|
|
66
|
+
comm=comm,
|
|
67
|
+
client=client.name,
|
|
68
|
+
exe=exe,
|
|
69
|
+
state=_STATE_WORDS.get(letter, letter),
|
|
70
|
+
rss_bytes=rss_pages * _page_size(),
|
|
71
|
+
cpu_seconds=round((utime + stime) / _clk_tck(), 3),
|
|
72
|
+
ppid=ppid,
|
|
73
|
+
cmdline=cmdline,
|
|
74
|
+
files=tuple(files),
|
|
75
|
+
connections=ordered,
|
|
76
|
+
children=tuple(children),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find_pids(proc_root: Path, name: str) -> list[int]:
|
|
81
|
+
root = Path(proc_root)
|
|
82
|
+
if not root.is_dir():
|
|
83
|
+
raise ProcError(f"{root} is not a process table")
|
|
84
|
+
matches: list[int] = []
|
|
85
|
+
for entry in root.iterdir():
|
|
86
|
+
if not entry.name.isdigit() or not entry.is_dir():
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
comm = _read_comm(entry)
|
|
90
|
+
exe = _read_exe(entry)
|
|
91
|
+
except ProcError:
|
|
92
|
+
continue
|
|
93
|
+
if comm == name or Path(exe).name == name:
|
|
94
|
+
matches.append(int(entry.name))
|
|
95
|
+
return sorted(matches)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_comm(base: Path) -> str:
|
|
99
|
+
path = base / "comm"
|
|
100
|
+
try:
|
|
101
|
+
return path.read_text().strip()
|
|
102
|
+
except OSError as exc:
|
|
103
|
+
raise ProcError(f"cannot read {path}") from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_exe(base: Path) -> str:
|
|
107
|
+
link = base / "exe"
|
|
108
|
+
if not link.is_symlink() and not link.exists():
|
|
109
|
+
return ""
|
|
110
|
+
try:
|
|
111
|
+
return os.readlink(link)
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise ProcError(f"cannot read {link}") from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_cmdline(base: Path) -> list[str]:
|
|
117
|
+
path = base / "cmdline"
|
|
118
|
+
if not path.is_file():
|
|
119
|
+
return []
|
|
120
|
+
raw = path.read_bytes().split(b"\0")
|
|
121
|
+
return [part.decode("utf-8", "replace") for part in raw if part]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_stat(path: Path) -> tuple[str, int, int, int, int]:
|
|
125
|
+
if not path.is_file():
|
|
126
|
+
raise ProcError(f"cannot read {path}")
|
|
127
|
+
text = path.read_text()
|
|
128
|
+
end = text.rfind(")")
|
|
129
|
+
if end < 0:
|
|
130
|
+
raise ProcError(f"bad stat line in {path}")
|
|
131
|
+
rest = text[end + 1 :].split()
|
|
132
|
+
if len(rest) < 22:
|
|
133
|
+
raise ProcError(f"short stat line in {path}")
|
|
134
|
+
return rest[0], int(rest[1]), int(rest[11]), int(rest[12]), int(rest[21])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _open_files(base: Path, comm: str) -> list[OpenFile]:
|
|
138
|
+
fd_dir = base / "fd"
|
|
139
|
+
if not fd_dir.is_dir():
|
|
140
|
+
return []
|
|
141
|
+
found: list[OpenFile] = []
|
|
142
|
+
for fd in fd_dir.iterdir():
|
|
143
|
+
try:
|
|
144
|
+
target = os.readlink(fd)
|
|
145
|
+
except OSError:
|
|
146
|
+
continue
|
|
147
|
+
if not target.startswith("/"):
|
|
148
|
+
continue
|
|
149
|
+
found.append(OpenFile(target, is_sensitive(target), comm))
|
|
150
|
+
return found
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _socket_inodes(base: Path) -> set[str]:
|
|
154
|
+
fd_dir = base / "fd"
|
|
155
|
+
inodes: set[str] = set()
|
|
156
|
+
if not fd_dir.is_dir():
|
|
157
|
+
return inodes
|
|
158
|
+
for fd in fd_dir.iterdir():
|
|
159
|
+
try:
|
|
160
|
+
target = os.readlink(fd)
|
|
161
|
+
except OSError:
|
|
162
|
+
continue
|
|
163
|
+
if target.startswith("socket:[") and target.endswith("]"):
|
|
164
|
+
inodes.add(target[len("socket:[") : -1])
|
|
165
|
+
return inodes
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _sockets(root: Path, pid: int, inodes: set[str]) -> list[RawSocket]:
|
|
169
|
+
if not inodes:
|
|
170
|
+
return []
|
|
171
|
+
found: list[RawSocket] = []
|
|
172
|
+
seen: set[str] = set()
|
|
173
|
+
for table in _TABLES:
|
|
174
|
+
protocol = "udp" if table.startswith("udp") else "tcp"
|
|
175
|
+
for path in (root / str(pid) / "net" / table, root / "net" / table):
|
|
176
|
+
if not path.is_file():
|
|
177
|
+
continue
|
|
178
|
+
for raw in parse_proc_net(path.read_text(), protocol):
|
|
179
|
+
if raw.inode in inodes and raw.inode not in seen:
|
|
180
|
+
seen.add(raw.inode)
|
|
181
|
+
found.append(raw)
|
|
182
|
+
break
|
|
183
|
+
return found
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _children(root: Path, pid: int) -> list[Child]:
|
|
187
|
+
children: list[Child] = []
|
|
188
|
+
for entry in root.iterdir():
|
|
189
|
+
if not entry.name.isdigit() or int(entry.name) == pid or not entry.is_dir():
|
|
190
|
+
continue
|
|
191
|
+
stat = entry / "stat"
|
|
192
|
+
if not stat.is_file():
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
_letter, ppid, *_rest = _read_stat(stat)
|
|
196
|
+
comm = _read_comm(entry)
|
|
197
|
+
exe = _read_exe(entry)
|
|
198
|
+
except ProcError:
|
|
199
|
+
continue
|
|
200
|
+
if ppid == pid:
|
|
201
|
+
children.append(Child(int(entry.name), comm, exe))
|
|
202
|
+
children.sort(key=lambda child: child.pid)
|
|
203
|
+
return children
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _page_size() -> int:
|
|
207
|
+
try:
|
|
208
|
+
return os.sysconf("SC_PAGE_SIZE") or 4096
|
|
209
|
+
except (ValueError, OSError):
|
|
210
|
+
return 4096
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _clk_tck() -> int:
|
|
214
|
+
try:
|
|
215
|
+
return os.sysconf("SC_CLK_TCK") or 100
|
|
216
|
+
except (ValueError, OSError):
|
|
217
|
+
return 100
|
chaintrace/redact.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Hide password-style command arguments. Paths stay visible."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_SECRET = re.compile(r"(password|mnemonic|privkey|private-key|privatekey|secret-key)", re.I)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def redact_argv(argv: list[str]) -> tuple[str, ...]:
|
|
11
|
+
redacted: list[str] = []
|
|
12
|
+
hide_next = False
|
|
13
|
+
for arg in argv:
|
|
14
|
+
if hide_next:
|
|
15
|
+
redacted.append(arg if _is_path(arg) else "[redacted]")
|
|
16
|
+
hide_next = False
|
|
17
|
+
continue
|
|
18
|
+
name, sep, value = _split(arg)
|
|
19
|
+
if sep and _is_secret(name):
|
|
20
|
+
shown = value if _is_path(value) else "[redacted]"
|
|
21
|
+
redacted.append(f"{name}{sep}{shown}")
|
|
22
|
+
continue
|
|
23
|
+
redacted.append(arg)
|
|
24
|
+
if sep == "" and _is_secret(name):
|
|
25
|
+
hide_next = True
|
|
26
|
+
return tuple(redacted)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _split(arg: str) -> tuple[str, str, str]:
|
|
30
|
+
if "=" in arg and arg.startswith("-"):
|
|
31
|
+
name, value = arg.split("=", 1)
|
|
32
|
+
return name, "=", value
|
|
33
|
+
return arg, "", ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_secret(name: str) -> bool:
|
|
37
|
+
flag = name.lstrip("-")
|
|
38
|
+
if "jwt" in flag.lower():
|
|
39
|
+
return False
|
|
40
|
+
return _SECRET.search(flag) is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_path(value: str) -> bool:
|
|
44
|
+
return value.startswith("/") or value.startswith("./") or value.startswith("../")
|
chaintrace/render.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Text, JSON, and markdown for one observation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from chaintrace.models import Finding, Observation
|
|
8
|
+
|
|
9
|
+
_KIND_ORDER = {
|
|
10
|
+
"listen": 0,
|
|
11
|
+
"local": 1,
|
|
12
|
+
"peer": 2,
|
|
13
|
+
"expected": 3,
|
|
14
|
+
"remote": 4,
|
|
15
|
+
"unknown": 5,
|
|
16
|
+
"metadata": 6,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_status(obs: Observation) -> str:
|
|
21
|
+
lines = [f"client {obs.client}", f"pid {obs.pid}", f"comm {obs.comm}"]
|
|
22
|
+
if obs.exe:
|
|
23
|
+
lines.append(f"exe {obs.exe}")
|
|
24
|
+
if obs.state != "traced":
|
|
25
|
+
lines.append(f"state {obs.state}")
|
|
26
|
+
lines.append(f"rss {obs.rss_bytes}")
|
|
27
|
+
lines.append(f"cpu {obs.cpu_seconds:.1f}s")
|
|
28
|
+
lines.append(f"ppid {obs.ppid}")
|
|
29
|
+
if obs.cmdline:
|
|
30
|
+
lines.append("cmdline " + " ".join(obs.cmdline))
|
|
31
|
+
if obs.children:
|
|
32
|
+
for child in obs.children:
|
|
33
|
+
lines.append(f"child {child.pid} {child.comm} {child.exe or 'unknown'}")
|
|
34
|
+
else:
|
|
35
|
+
lines.append("child none")
|
|
36
|
+
for event in obs.events:
|
|
37
|
+
if event.kind == "exec":
|
|
38
|
+
lines.append(f"exec {event.path}")
|
|
39
|
+
elif event.kind == "chmod":
|
|
40
|
+
lines.append(f"chmod {event.path} {event.mode}")
|
|
41
|
+
if obs.files:
|
|
42
|
+
for item in obs.files:
|
|
43
|
+
mark = " sensitive" if item.sensitive else ""
|
|
44
|
+
lines.append(f"file {item.path} {item.comm}{mark}")
|
|
45
|
+
else:
|
|
46
|
+
lines.append("file none")
|
|
47
|
+
if obs.connections:
|
|
48
|
+
ordered = sorted(obs.connections, key=lambda item: (_KIND_ORDER.get(item.kind, 9), item.local, item.remote))
|
|
49
|
+
for conn in ordered:
|
|
50
|
+
if conn.kind == "listen" or conn.remote.endswith(":0"):
|
|
51
|
+
lines.append(f"{conn.kind} {conn.local} {conn.protocol}")
|
|
52
|
+
elif conn.local == "-":
|
|
53
|
+
lines.append(f"{conn.kind} {conn.remote} {conn.protocol}")
|
|
54
|
+
else:
|
|
55
|
+
lines.append(f"{conn.kind} {conn.local} {conn.remote} {conn.protocol}")
|
|
56
|
+
else:
|
|
57
|
+
lines.append("connection none")
|
|
58
|
+
return "\n".join(lines) + "\n"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_findings(findings: list[Finding]) -> str:
|
|
62
|
+
if not findings:
|
|
63
|
+
return "ok\n"
|
|
64
|
+
lines = [f"{item.severity} {item.rule} {item.message}" for item in findings]
|
|
65
|
+
if all(item.severity == "info" for item in findings):
|
|
66
|
+
lines.append("ok")
|
|
67
|
+
return "\n".join(lines) + "\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render_check(obs: Observation, findings: list[Finding]) -> str:
|
|
71
|
+
return render_status(obs) + "\n" + render_findings(findings)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_report(obs: Observation, findings: list[Finding]) -> str:
|
|
75
|
+
return (
|
|
76
|
+
"# ChainTrace\n\n"
|
|
77
|
+
"What this node is doing internally.\n\n"
|
|
78
|
+
"## Status\n\n"
|
|
79
|
+
"```\n"
|
|
80
|
+
f"{render_status(obs)}"
|
|
81
|
+
"```\n\n"
|
|
82
|
+
"## Findings\n\n"
|
|
83
|
+
"```\n"
|
|
84
|
+
f"{render_findings(findings)}"
|
|
85
|
+
"```\n"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render_json(obs: Observation, findings: list[Finding] | None = None) -> str:
|
|
90
|
+
body: dict[str, object] = {
|
|
91
|
+
"client": obs.client,
|
|
92
|
+
"pid": obs.pid,
|
|
93
|
+
"comm": obs.comm,
|
|
94
|
+
"exe": obs.exe,
|
|
95
|
+
"state": obs.state,
|
|
96
|
+
"rss_bytes": obs.rss_bytes,
|
|
97
|
+
"cpu_seconds": obs.cpu_seconds,
|
|
98
|
+
"ppid": obs.ppid,
|
|
99
|
+
"cmdline": list(obs.cmdline),
|
|
100
|
+
"children": [{"pid": child.pid, "comm": child.comm, "exe": child.exe} for child in obs.children],
|
|
101
|
+
"files": [{"path": item.path, "comm": item.comm, "sensitive": item.sensitive} for item in obs.files],
|
|
102
|
+
"connections": [
|
|
103
|
+
{
|
|
104
|
+
"kind": conn.kind,
|
|
105
|
+
"local": conn.local,
|
|
106
|
+
"remote": conn.remote,
|
|
107
|
+
"protocol": conn.protocol,
|
|
108
|
+
"state": conn.state,
|
|
109
|
+
}
|
|
110
|
+
for conn in obs.connections
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
if findings is not None:
|
|
114
|
+
body["findings"] = [
|
|
115
|
+
{"severity": item.severity, "rule": item.rule, "message": item.message} for item in findings
|
|
116
|
+
]
|
|
117
|
+
return json.dumps(body, indent=2) + "\n"
|
chaintrace/rules.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Findings from a process snapshot or a kernel trace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from chaintrace.baseline import Baseline
|
|
8
|
+
from chaintrace.clients import Client, ClientError, client_named
|
|
9
|
+
from chaintrace.models import Finding, Observation
|
|
10
|
+
from chaintrace.net import is_loopback, split_endpoint, suspicious_port
|
|
11
|
+
|
|
12
|
+
UNKNOWN_LIMIT = 5
|
|
13
|
+
SHELLS = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"bash",
|
|
16
|
+
"sh",
|
|
17
|
+
"dash",
|
|
18
|
+
"ash",
|
|
19
|
+
"zsh",
|
|
20
|
+
"ksh",
|
|
21
|
+
"curl",
|
|
22
|
+
"wget",
|
|
23
|
+
"nc",
|
|
24
|
+
"ncat",
|
|
25
|
+
"socat",
|
|
26
|
+
"python",
|
|
27
|
+
"python3",
|
|
28
|
+
"perl",
|
|
29
|
+
"ruby",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
_PERSISTENCE = (
|
|
33
|
+
"/etc/ld.so.preload",
|
|
34
|
+
"/etc/crontab",
|
|
35
|
+
"/etc/cron",
|
|
36
|
+
"/var/spool/cron",
|
|
37
|
+
"/etc/sudoers",
|
|
38
|
+
"/etc/systemd/system",
|
|
39
|
+
"/etc/rc.local",
|
|
40
|
+
)
|
|
41
|
+
_RANK = {"critical": 0, "warning": 1, "info": 2}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def evaluate(obs: Observation, baseline: Baseline | None = None) -> list[Finding]:
|
|
45
|
+
client = _profile(obs)
|
|
46
|
+
found: list[Finding] = []
|
|
47
|
+
if "(deleted)" in obs.exe:
|
|
48
|
+
found.append(Finding("warning", "deleted-binary", obs.exe))
|
|
49
|
+
if baseline and obs.exe and baseline.exe and obs.exe != baseline.exe:
|
|
50
|
+
found.append(Finding("warning", "binary-changed", f"was {baseline.exe} now {obs.exe}"))
|
|
51
|
+
for child in obs.children:
|
|
52
|
+
if child.comm in SHELLS:
|
|
53
|
+
found.append(Finding("critical", "shell-child", f"pid {child.pid} {child.comm}"))
|
|
54
|
+
for event in obs.events:
|
|
55
|
+
if event.kind == "exec":
|
|
56
|
+
finding = _exec(event.path, client, obs.comm)
|
|
57
|
+
if finding is not None:
|
|
58
|
+
found.append(finding)
|
|
59
|
+
elif event.kind == "chmod":
|
|
60
|
+
found.append(_chmod(event.path, event.mode))
|
|
61
|
+
for item in obs.files:
|
|
62
|
+
if _persistence(item.path):
|
|
63
|
+
found.append(Finding("critical", "persistence", item.path))
|
|
64
|
+
elif item.sensitive:
|
|
65
|
+
severity = "info" if item.comm == obs.comm or item.comm in client.comms else "warning"
|
|
66
|
+
found.append(Finding(severity, "sensitive-file", item.path))
|
|
67
|
+
unknown: set[str] = set()
|
|
68
|
+
for conn in obs.connections:
|
|
69
|
+
if conn.kind == "metadata":
|
|
70
|
+
found.append(Finding("critical", "metadata-destination", conn.remote))
|
|
71
|
+
elif suspicious_port(conn):
|
|
72
|
+
found.append(Finding("warning", "suspicious-port", conn.remote))
|
|
73
|
+
elif conn.kind == "unknown":
|
|
74
|
+
unknown.add(conn.remote)
|
|
75
|
+
if baseline and conn.remote not in baseline.unknown:
|
|
76
|
+
found.append(Finding("warning", "new-destination", conn.remote))
|
|
77
|
+
elif baseline is None:
|
|
78
|
+
found.append(Finding("info", "unknown-destination", conn.remote))
|
|
79
|
+
elif conn.kind == "listen":
|
|
80
|
+
found.extend(_listen(conn.local, client, baseline))
|
|
81
|
+
if len(unknown) >= UNKNOWN_LIMIT:
|
|
82
|
+
found.append(Finding("warning", "traffic-anomaly", f"{len(unknown)} unknown destinations"))
|
|
83
|
+
unique = list(dict.fromkeys(found))
|
|
84
|
+
unique.sort(key=lambda item: (_RANK[item.severity], item.rule, item.message))
|
|
85
|
+
return unique
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def exit_code(findings: list[Finding]) -> int:
|
|
89
|
+
if any(item.severity == "critical" for item in findings):
|
|
90
|
+
return 2
|
|
91
|
+
if any(item.severity == "warning" for item in findings):
|
|
92
|
+
return 1
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _profile(obs: Observation) -> Client:
|
|
97
|
+
if obs.client == "unknown":
|
|
98
|
+
return Client("unknown", frozenset({obs.comm}), frozenset(), frozenset(), frozenset())
|
|
99
|
+
try:
|
|
100
|
+
return client_named(obs.client)
|
|
101
|
+
except ClientError:
|
|
102
|
+
return Client("unknown", frozenset({obs.comm}), frozenset(), frozenset(), frozenset())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _exec(path: str, client: Client, comm: str) -> Finding | None:
|
|
106
|
+
name = Path(path).name
|
|
107
|
+
if name in SHELLS:
|
|
108
|
+
return Finding("critical", "unexpected-exec", path)
|
|
109
|
+
if name and name not in client.comms and name != comm:
|
|
110
|
+
return Finding("warning", "unexpected-exec", path)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _chmod(path: str, mode: str) -> Finding:
|
|
115
|
+
severity = "critical" if _other_writable(mode) else "warning"
|
|
116
|
+
return Finding(severity, "permission-change", f"{path} mode {mode}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _listen(local: str, client: Client, baseline: Baseline | None) -> list[Finding]:
|
|
120
|
+
found: list[Finding] = []
|
|
121
|
+
try:
|
|
122
|
+
ip, port = split_endpoint(local)
|
|
123
|
+
except ValueError:
|
|
124
|
+
return found
|
|
125
|
+
if port in client.loopback_ports and not is_loopback(ip):
|
|
126
|
+
found.append(Finding("warning", "exposed-port", local))
|
|
127
|
+
if baseline and local not in baseline.listen:
|
|
128
|
+
found.append(Finding("warning", "new-listener", local))
|
|
129
|
+
return found
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _persistence(path: str) -> bool:
|
|
133
|
+
if "/.ssh/authorized_keys" in path:
|
|
134
|
+
return True
|
|
135
|
+
for prefix in _PERSISTENCE:
|
|
136
|
+
if path == prefix or path.startswith(prefix + "/") or path.startswith(prefix + "."):
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _other_writable(mode: str) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
value = int(mode, 8) if mode.startswith("0") and len(mode) > 1 else int(mode, 10)
|
|
144
|
+
except ValueError:
|
|
145
|
+
return False
|
|
146
|
+
return bool(value & 0o002)
|