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 ADDED
@@ -0,0 +1,3 @@
1
+ """See what a blockchain node is doing."""
2
+
3
+ __version__ = "0.1.0"
chaintrace/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from chaintrace.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
chaintrace/baseline.py ADDED
@@ -0,0 +1,81 @@
1
+ """Remember the binary, the listeners, and the unknown destinations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from chaintrace.models import Observation
11
+
12
+
13
+ class BaselineError(ValueError):
14
+ """The baseline file could not be read."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Baseline:
19
+ client: str
20
+ exe: str
21
+ unknown: frozenset[str]
22
+ listen: frozenset[str]
23
+
24
+
25
+ def baseline_key(obs: Observation) -> str:
26
+ return obs.comm if obs.client == "unknown" else obs.client
27
+
28
+
29
+ def home_file() -> Path:
30
+ override = os.environ.get("CHAINTRACE_HOME")
31
+ root = Path(override) if override else Path.home() / ".chaintrace"
32
+ return root / "baseline.json"
33
+
34
+
35
+ def capture(obs: Observation) -> Baseline:
36
+ return Baseline(
37
+ client=baseline_key(obs),
38
+ exe=obs.exe,
39
+ unknown=frozenset(conn.remote for conn in obs.connections if conn.kind == "unknown"),
40
+ listen=frozenset(conn.local for conn in obs.connections if conn.kind == "listen"),
41
+ )
42
+
43
+
44
+ def load(path: Path, client: str) -> Baseline | None:
45
+ if not path.is_file():
46
+ return None
47
+ try:
48
+ raw = json.loads(path.read_text())
49
+ except json.JSONDecodeError as exc:
50
+ raise BaselineError(f"{path} is not a baseline file") from exc
51
+ item = raw.get(client)
52
+ if not isinstance(item, dict):
53
+ return None
54
+ return Baseline(
55
+ client=client,
56
+ exe=str(item.get("exe", "")),
57
+ unknown=frozenset(item.get("unknown", [])),
58
+ listen=frozenset(item.get("listen", [])),
59
+ )
60
+
61
+
62
+ def save(path: Path, baseline: Baseline) -> None:
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ path.parent.chmod(0o700)
65
+ data: dict[str, object] = {}
66
+ if path.is_file():
67
+ try:
68
+ loaded = json.loads(path.read_text())
69
+ except json.JSONDecodeError as exc:
70
+ raise BaselineError(f"{path} is not a baseline file") from exc
71
+ if isinstance(loaded, dict):
72
+ data = loaded
73
+ data[baseline.client] = {
74
+ "exe": baseline.exe,
75
+ "unknown": sorted(baseline.unknown),
76
+ "listen": sorted(baseline.listen),
77
+ }
78
+ temporary = path.with_suffix(".json.tmp")
79
+ temporary.write_text(json.dumps(data, indent=2) + "\n")
80
+ temporary.chmod(0o600)
81
+ temporary.replace(path)
chaintrace/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ """Command line for chaintrace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from chaintrace import __version__
12
+ from chaintrace.baseline import BaselineError, baseline_key, capture, home_file, load, save
13
+ from chaintrace.clients import ClientError, apply_client
14
+ from chaintrace.models import Observation
15
+ from chaintrace.probe import render_probe, unavailable
16
+ from chaintrace.procfs import ProcError, find_pids, read_pid
17
+ from chaintrace.render import render_check, render_json, render_report, render_status
18
+ from chaintrace.rules import evaluate, exit_code
19
+ from chaintrace.trace import TraceError, read_trace
20
+
21
+
22
+ def main(argv: list[str] | None = None) -> int:
23
+ parser = _parser()
24
+ args = parser.parse_args(argv)
25
+ if args.version:
26
+ print(f"chaintrace {__version__}")
27
+ return 0
28
+ if not args.command:
29
+ parser.print_help(sys.stderr)
30
+ return 3
31
+ try:
32
+ return args.func(args)
33
+ except (ProcError, TraceError, ClientError, BaselineError) as exc:
34
+ print(exc, file=sys.stderr)
35
+ return 3
36
+
37
+
38
+ def _parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(prog="chaintrace", description="See what a blockchain node is doing.")
40
+ parser.add_argument("--version", action="store_true")
41
+ commands = parser.add_subparsers(dest="command")
42
+
43
+ status = commands.add_parser("status", help="Show the process, files, and connections")
44
+ _process_flags(status)
45
+ status.set_defaults(func=cmd_status, trace=None, no_baseline=True)
46
+
47
+ check = commands.add_parser("check", help="Report behavior that does not belong on a node")
48
+ _process_flags(check)
49
+ check.add_argument("--trace", type=Path)
50
+ check.add_argument("--no-baseline", action="store_true")
51
+ check.set_defaults(func=cmd_check)
52
+
53
+ baseline = commands.add_parser("baseline", help="Remember this binary, its listeners, and unknown destinations")
54
+ _process_flags(baseline)
55
+ baseline.set_defaults(func=cmd_baseline, trace=None, no_baseline=True)
56
+
57
+ report = commands.add_parser("report", help="Write a markdown report")
58
+ _process_flags(report)
59
+ report.add_argument("--trace", type=Path)
60
+ report.add_argument("--no-baseline", action="store_true")
61
+ report.add_argument("--output", type=Path, default=Path("chaintrace-report.md"))
62
+ report.set_defaults(func=cmd_report)
63
+
64
+ watch = commands.add_parser("watch", help="Trace exec, connect, open, and chmod for one pid")
65
+ watch.add_argument("--pid", type=int, required=True)
66
+ watch.add_argument("--seconds", type=int, default=10)
67
+ watch.set_defaults(func=cmd_watch)
68
+ return parser
69
+
70
+
71
+ def _process_flags(parser: argparse.ArgumentParser) -> None:
72
+ parser.add_argument("--proc", type=Path, help="Process table. Defaults to /proc")
73
+ parser.add_argument("--pid", type=int)
74
+ parser.add_argument("--name", help="Process name, such as geth or lighthouse")
75
+ parser.add_argument("--client", help="Client profile when the process name is generic")
76
+ parser.add_argument("--json", action="store_true")
77
+
78
+
79
+ def cmd_status(args: argparse.Namespace) -> int:
80
+ obs = _load(args)
81
+ print(render_json(obs) if args.json else render_status(obs), end="")
82
+ return 0
83
+
84
+
85
+ def cmd_check(args: argparse.Namespace) -> int:
86
+ obs = _load(args)
87
+ findings = evaluate(obs, _stored(args, obs))
88
+ print(render_json(obs, findings) if args.json else render_check(obs, findings), end="")
89
+ return exit_code(findings)
90
+
91
+
92
+ def cmd_baseline(args: argparse.Namespace) -> int:
93
+ obs = _load(args)
94
+ saved = capture(obs)
95
+ path = home_file()
96
+ save(path, saved)
97
+ print(f"baseline {saved.client} {path}")
98
+ return 0
99
+
100
+
101
+ def cmd_report(args: argparse.Namespace) -> int:
102
+ obs = _load(args)
103
+ findings = evaluate(obs, _stored(args, obs))
104
+ args.output.write_text(render_report(obs, findings))
105
+ print(f"report {args.output}")
106
+ return exit_code(findings)
107
+
108
+
109
+ def cmd_watch(args: argparse.Namespace) -> int:
110
+ try:
111
+ script = render_probe(args.pid, args.seconds)
112
+ except ValueError as exc:
113
+ print(exc, file=sys.stderr)
114
+ return 3
115
+ reason = unavailable()
116
+ if reason:
117
+ print(reason, file=sys.stderr)
118
+ return 3
119
+ with tempfile.TemporaryDirectory() as directory:
120
+ path = Path(directory) / "node.bt"
121
+ path.write_text(script)
122
+ try:
123
+ completed = subprocess.run(["bpftrace", str(path)], check=False, timeout=args.seconds + 20)
124
+ except subprocess.TimeoutExpired:
125
+ print("bpftrace did not exit", file=sys.stderr)
126
+ return 3
127
+ return 0 if completed.returncode == 0 else 3
128
+
129
+
130
+ def _stored(args: argparse.Namespace, obs: Observation):
131
+ if args.no_baseline:
132
+ return None
133
+ return load(home_file(), baseline_key(obs))
134
+
135
+
136
+ def _load(args: argparse.Namespace):
137
+ trace = getattr(args, "trace", None)
138
+ if trace and (args.pid or args.name):
139
+ raise TraceError("pass a trace or a process, not both")
140
+ if args.pid and args.name:
141
+ raise ProcError("pass --pid or --name, not both")
142
+ if trace:
143
+ return apply_client(read_trace(trace), args.client)
144
+ proc = args.proc if args.proc is not None else Path("/proc")
145
+ if not proc.is_dir():
146
+ raise ProcError(f"{proc} is not a process table")
147
+ if args.name:
148
+ found = find_pids(proc, args.name)
149
+ if not found:
150
+ raise ProcError(f"no process named {args.name}")
151
+ if len(found) > 1:
152
+ listed = ", ".join(str(item) for item in found)
153
+ raise ProcError(f"more than one {args.name}: {listed}")
154
+ pid = found[0]
155
+ elif args.pid is None:
156
+ raise ProcError("pass --pid or --name")
157
+ else:
158
+ pid = args.pid
159
+ return apply_client(read_pid(proc, pid), args.client)
@@ -0,0 +1,69 @@
1
+ {
2
+ "geth": {
3
+ "comms": ["geth"],
4
+ "p2p_ports": [30303],
5
+ "loopback_ports": [8545, 8551]
6
+ },
7
+ "reth": {
8
+ "comms": ["reth"],
9
+ "p2p_ports": [30303],
10
+ "loopback_ports": [8545, 8551]
11
+ },
12
+ "nethermind": {
13
+ "comms": ["nethermind", "Nethermind"],
14
+ "p2p_ports": [30303],
15
+ "loopback_ports": [8545, 8551]
16
+ },
17
+ "erigon": {
18
+ "comms": ["erigon"],
19
+ "p2p_ports": [30303],
20
+ "loopback_ports": [8545, 8551]
21
+ },
22
+ "besu": {
23
+ "comms": ["besu"],
24
+ "p2p_ports": [30303],
25
+ "loopback_ports": [8545, 8551]
26
+ },
27
+ "lighthouse": {
28
+ "comms": ["lighthouse"],
29
+ "p2p_ports": [9000],
30
+ "loopback_ports": [5052]
31
+ },
32
+ "prysm": {
33
+ "comms": ["prysm", "beacon-chain"],
34
+ "p2p_ports": [13000, 12000],
35
+ "loopback_ports": [3500, 4000]
36
+ },
37
+ "teku": {
38
+ "comms": ["teku"],
39
+ "p2p_ports": [9000],
40
+ "loopback_ports": [5051]
41
+ },
42
+ "nimbus": {
43
+ "comms": ["nimbus", "nimbus_beacon_node", "nimbus_beacon_n"],
44
+ "p2p_ports": [9000],
45
+ "loopback_ports": [5052]
46
+ },
47
+ "lodestar": {
48
+ "comms": ["lodestar"],
49
+ "p2p_ports": [9000],
50
+ "loopback_ports": [9596]
51
+ },
52
+ "mev-boost": {
53
+ "comms": ["mev-boost"],
54
+ "p2p_ports": [],
55
+ "remote_ports": [443, 18550],
56
+ "loopback_ports": [18550]
57
+ },
58
+ "op-node": {
59
+ "comms": ["op-node"],
60
+ "p2p_ports": [9222],
61
+ "remote_ports": [443],
62
+ "loopback_ports": [9545]
63
+ },
64
+ "op-geth": {
65
+ "comms": ["op-geth"],
66
+ "p2p_ports": [30303],
67
+ "loopback_ports": [8545, 8551]
68
+ }
69
+ }
chaintrace/clients.py ADDED
@@ -0,0 +1,106 @@
1
+ """Client profiles shipped with the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from chaintrace.models import Observation
12
+
13
+
14
+ class ClientError(ValueError):
15
+ """The named client is not in the catalog."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Client:
20
+ name: str
21
+ comms: frozenset[str]
22
+ p2p_ports: frozenset[int]
23
+ remote_ports: frozenset[int]
24
+ loopback_ports: frozenset[int]
25
+
26
+ @property
27
+ def profiled(self) -> bool:
28
+ return self.name != "unknown"
29
+
30
+
31
+ @lru_cache(maxsize=1)
32
+ def catalog() -> tuple[Client, ...]:
33
+ raw = json.loads(files("chaintrace").joinpath("clients.json").read_text())
34
+ clients = []
35
+ for name, item in raw.items():
36
+ clients.append(
37
+ Client(
38
+ name=name,
39
+ comms=frozenset(item.get("comms", [])),
40
+ p2p_ports=frozenset(item.get("p2p_ports", [])),
41
+ remote_ports=frozenset(item.get("remote_ports", [])),
42
+ loopback_ports=frozenset(item.get("loopback_ports", [])),
43
+ )
44
+ )
45
+ return tuple(clients)
46
+
47
+
48
+ def client_named(name: str) -> Client:
49
+ for client in catalog():
50
+ if client.name == name:
51
+ return client
52
+ known = ", ".join(client.name for client in catalog())
53
+ raise ClientError(f"unknown client {name}. known clients: {known}")
54
+
55
+
56
+ def _basename(exe: str) -> str:
57
+ name = Path(exe).name
58
+ suffix = " (deleted)"
59
+ if name.endswith(suffix):
60
+ return name[: -len(suffix)]
61
+ return name
62
+
63
+
64
+ def recognize(comm: str, exe: str) -> Client:
65
+ """Match the executable name first, then the process name."""
66
+ base = _basename(exe)
67
+ if base:
68
+ for client in catalog():
69
+ if base in client.comms:
70
+ return client
71
+ for client in catalog():
72
+ if comm in client.comms:
73
+ return client
74
+ return Client("unknown", frozenset({comm} if comm else set()), frozenset(), frozenset(), frozenset())
75
+
76
+
77
+ def resolve(comm: str, exe: str, override: str | None) -> Client:
78
+ if override:
79
+ return client_named(override)
80
+ return recognize(comm, exe)
81
+
82
+
83
+ def apply_client(obs: Observation, override: str | None) -> Observation:
84
+ client = resolve(obs.comm, obs.exe, override)
85
+ if client.name == obs.client and override is None:
86
+ return obs
87
+ from chaintrace.net import classify_connection
88
+
89
+ connections = tuple(
90
+ classify_connection(conn, client) for conn in obs.connections
91
+ )
92
+ return Observation(
93
+ pid=obs.pid,
94
+ comm=obs.comm,
95
+ client=client.name,
96
+ exe=obs.exe,
97
+ state=obs.state,
98
+ rss_bytes=obs.rss_bytes,
99
+ cpu_seconds=obs.cpu_seconds,
100
+ ppid=obs.ppid,
101
+ cmdline=obs.cmdline,
102
+ files=obs.files,
103
+ connections=connections,
104
+ children=obs.children,
105
+ events=obs.events,
106
+ )
chaintrace/models.py ADDED
@@ -0,0 +1,62 @@
1
+ """Process snapshot and findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class OpenFile:
10
+ path: str
11
+ sensitive: bool
12
+ comm: str
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Connection:
17
+ protocol: str
18
+ local: str
19
+ remote: str
20
+ state: str
21
+ kind: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Child:
26
+ pid: int
27
+ comm: str
28
+ exe: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Event:
33
+ kind: str
34
+ pid: int
35
+ comm: str
36
+ path: str = ""
37
+ remote: str = ""
38
+ mode: str = ""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Observation:
43
+ pid: int
44
+ comm: str
45
+ client: str
46
+ exe: str
47
+ state: str
48
+ rss_bytes: int
49
+ cpu_seconds: float
50
+ ppid: int
51
+ cmdline: tuple[str, ...]
52
+ files: tuple[OpenFile, ...]
53
+ connections: tuple[Connection, ...]
54
+ children: tuple[Child, ...]
55
+ events: tuple[Event, ...] = ()
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Finding:
60
+ severity: str
61
+ rule: str
62
+ message: str
chaintrace/net.py ADDED
@@ -0,0 +1,179 @@
1
+ """Parse /proc socket tables and classify a destination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ from dataclasses import dataclass
7
+
8
+ from chaintrace.clients import Client
9
+ from chaintrace.models import Connection
10
+
11
+ METADATA_HOSTS = frozenset({"169.254.169.254", "169.254.170.2", "fd00:ec2::254"})
12
+ SUSPICIOUS_PORTS = frozenset({22, 23, 25, 445, 3389, 4444, 1337, 31337})
13
+
14
+ _STATES = {
15
+ "01": "ESTABLISHED",
16
+ "02": "SYN_SENT",
17
+ "03": "SYN_RECV",
18
+ "04": "FIN_WAIT1",
19
+ "05": "FIN_WAIT2",
20
+ "06": "TIME_WAIT",
21
+ "07": "CLOSE",
22
+ "08": "CLOSE_WAIT",
23
+ "09": "LAST_ACK",
24
+ "0A": "LISTEN",
25
+ "0B": "CLOSING",
26
+ }
27
+
28
+
29
+ class NetError(ValueError):
30
+ """A socket address could not be parsed."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RawSocket:
35
+ protocol: str
36
+ local_ip: str
37
+ local_port: int
38
+ remote_ip: str
39
+ remote_port: int
40
+ state: str
41
+ inode: str
42
+
43
+
44
+ def parse_proc_net(text: str, protocol: str) -> tuple[RawSocket, ...]:
45
+ """Parse `/proc/net/tcp` or the udp, tcp6, and udp6 tables.
46
+
47
+ The inode is the 10th column. See proc(5).
48
+ """
49
+ found: list[RawSocket] = []
50
+ for line in text.splitlines():
51
+ parts = line.split()
52
+ if len(parts) < 10 or ":" not in parts[1] or ":" not in parts[2]:
53
+ continue
54
+ try:
55
+ local_ip, local_port = _address(parts[1])
56
+ remote_ip, remote_port = _address(parts[2])
57
+ except NetError:
58
+ continue
59
+ found.append(
60
+ RawSocket(
61
+ protocol=protocol,
62
+ local_ip=local_ip,
63
+ local_port=local_port,
64
+ remote_ip=remote_ip,
65
+ remote_port=remote_port,
66
+ state=_STATES.get(parts[3].upper(), parts[3]),
67
+ inode=parts[9],
68
+ )
69
+ )
70
+ return tuple(found)
71
+
72
+
73
+ def _address(field: str) -> tuple[str, int]:
74
+ hex_ip, _, hex_port = field.partition(":")
75
+ if not hex_port:
76
+ raise NetError(f"bad socket address {field}")
77
+ return _ip(hex_ip), int(hex_port, 16)
78
+
79
+
80
+ def _ip(hex_ip: str) -> str:
81
+ if len(hex_ip) == 8:
82
+ raw = bytes.fromhex(hex_ip)
83
+ return ".".join(str(b) for b in raw[::-1])
84
+ if len(hex_ip) == 32:
85
+ raw = bytes.fromhex(hex_ip)
86
+ words = [raw[i : i + 4][::-1] for i in range(0, 16, 4)]
87
+ return socket.inet_ntop(socket.AF_INET6, b"".join(words))
88
+ raise NetError(f"bad ip length {len(hex_ip)}")
89
+
90
+
91
+ def format_endpoint(ip: str, port: int) -> str:
92
+ if ":" in ip:
93
+ return f"[{ip}]:{port}"
94
+ return f"{ip}:{port}"
95
+
96
+
97
+ def split_endpoint(endpoint: str) -> tuple[str, int]:
98
+ if endpoint.startswith("["):
99
+ host, _, port = endpoint[1:].rpartition("]:")
100
+ return host, int(port)
101
+ host, _, port = endpoint.rpartition(":")
102
+ if not host or not port:
103
+ raise NetError(f"bad endpoint {endpoint}")
104
+ return host, int(port)
105
+
106
+
107
+ def is_loopback(ip: str) -> bool:
108
+ if ip == "::1":
109
+ return True
110
+ return ip.startswith("127.")
111
+
112
+
113
+ def is_unspecified(ip: str) -> bool:
114
+ return ip in {"0.0.0.0", "::"}
115
+
116
+
117
+ def is_metadata(ip: str) -> bool:
118
+ return ip in METADATA_HOSTS
119
+
120
+
121
+ def classify(raw: RawSocket, client: Client) -> str:
122
+ listening = raw.state == "LISTEN" or raw.remote_port == 0
123
+ if listening:
124
+ return "listen"
125
+ if is_metadata(raw.remote_ip):
126
+ return "metadata"
127
+ if is_loopback(raw.remote_ip):
128
+ return "local"
129
+ if not client.profiled:
130
+ return "remote"
131
+ if raw.remote_port in client.p2p_ports or raw.local_port in client.p2p_ports:
132
+ return "peer"
133
+ if raw.remote_port in client.remote_ports:
134
+ return "expected"
135
+ return "unknown"
136
+
137
+
138
+ def connection_from_raw(raw: RawSocket, client: Client) -> Connection:
139
+ return Connection(
140
+ protocol=raw.protocol,
141
+ local=format_endpoint(raw.local_ip, raw.local_port),
142
+ remote=format_endpoint(raw.remote_ip, raw.remote_port),
143
+ state=raw.state,
144
+ kind=classify(raw, client),
145
+ )
146
+
147
+
148
+ def classify_connection(conn: Connection, client: Client) -> Connection:
149
+ """Re-label a connection after the operator names the client."""
150
+ if conn.local == "-":
151
+ local_ip, local_port = "0.0.0.0", 0
152
+ else:
153
+ local_ip, local_port = split_endpoint(conn.local)
154
+ remote_ip, remote_port = split_endpoint(conn.remote)
155
+ raw = RawSocket(
156
+ protocol=conn.protocol,
157
+ local_ip=local_ip,
158
+ local_port=local_port,
159
+ remote_ip=remote_ip,
160
+ remote_port=remote_port,
161
+ state=conn.state,
162
+ inode="",
163
+ )
164
+ kind = classify(raw, client)
165
+ if kind == conn.kind:
166
+ return conn
167
+ return Connection(conn.protocol, conn.local, conn.remote, conn.state, kind)
168
+
169
+
170
+ def suspicious_port(conn: Connection) -> bool:
171
+ if conn.kind in {"listen", "local"}:
172
+ return False
173
+ try:
174
+ remote_ip, remote_port = split_endpoint(conn.remote)
175
+ except NetError:
176
+ return False
177
+ if is_loopback(remote_ip):
178
+ return False
179
+ return remote_port in SUSPICIOUS_PORTS