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/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)