chaintrace 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- chaintrace-0.1.0/PKG-INFO +139 -0
- chaintrace-0.1.0/README.md +113 -0
- chaintrace-0.1.0/pyproject.toml +55 -0
- chaintrace-0.1.0/setup.cfg +4 -0
- chaintrace-0.1.0/src/chaintrace/__init__.py +3 -0
- chaintrace-0.1.0/src/chaintrace/__main__.py +4 -0
- chaintrace-0.1.0/src/chaintrace/baseline.py +81 -0
- chaintrace-0.1.0/src/chaintrace/cli.py +159 -0
- chaintrace-0.1.0/src/chaintrace/clients.json +69 -0
- chaintrace-0.1.0/src/chaintrace/clients.py +106 -0
- chaintrace-0.1.0/src/chaintrace/models.py +62 -0
- chaintrace-0.1.0/src/chaintrace/net.py +179 -0
- chaintrace-0.1.0/src/chaintrace/probe.py +71 -0
- chaintrace-0.1.0/src/chaintrace/procfs.py +217 -0
- chaintrace-0.1.0/src/chaintrace/redact.py +44 -0
- chaintrace-0.1.0/src/chaintrace/render.py +117 -0
- chaintrace-0.1.0/src/chaintrace/rules.py +146 -0
- chaintrace-0.1.0/src/chaintrace/trace.py +118 -0
- chaintrace-0.1.0/src/chaintrace.egg-info/PKG-INFO +139 -0
- chaintrace-0.1.0/src/chaintrace.egg-info/SOURCES.txt +28 -0
- chaintrace-0.1.0/src/chaintrace.egg-info/dependency_links.txt +1 -0
- chaintrace-0.1.0/src/chaintrace.egg-info/entry_points.txt +2 -0
- chaintrace-0.1.0/src/chaintrace.egg-info/requires.txt +3 -0
- chaintrace-0.1.0/src/chaintrace.egg-info/top_level.txt +1 -0
- chaintrace-0.1.0/tests/test_cli.py +105 -0
- chaintrace-0.1.0/tests/test_net.py +54 -0
- chaintrace-0.1.0/tests/test_proc.py +69 -0
- chaintrace-0.1.0/tests/test_rules.py +111 -0
- chaintrace-0.1.0/tests/test_trace.py +64 -0
chaintrace-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chaintrace contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chaintrace
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See what a blockchain node is doing, from the process table and a short kernel trace.
|
|
5
|
+
Author: ehsanhajian
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/ehsanhajian/chaintrace
|
|
8
|
+
Project-URL: Issues, https://github.com/ehsanhajian/chaintrace/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/ehsanhajian/chaintrace/releases
|
|
10
|
+
Keywords: ebpf,bpftrace,ethereum,geth,lighthouse,security,node
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# ChainTrace
|
|
28
|
+
|
|
29
|
+
ChainTrace answers one question: **what is my node doing internally?**
|
|
30
|
+
|
|
31
|
+
It reads the process, the files it has open, and the connections it holds. When you ask, it also attaches a short read-only kernel probe. A finding is printed when that behavior does not belong on a node.
|
|
32
|
+
|
|
33
|
+
It does not stop the process, read a file, open a connection, or store a key.
|
|
34
|
+
|
|
35
|
+
ValidatorPulse raises an alert. ChainDiff decides whether an upgrade is safe. chainlog-ai explains a failure from the logs. ChainTrace is the view of the process itself.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install chaintrace
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Python 3.11 or newer. The package is published from a GitHub release to [PyPI](https://pypi.org/project/chaintrace/).
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
chaintrace --version
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Which command
|
|
50
|
+
|
|
51
|
+
| Question | Command |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| What is this process doing? | `status --pid` or `status --name` |
|
|
54
|
+
| Is any of that suspicious? | `check` |
|
|
55
|
+
| What should the next check compare against? | `baseline` |
|
|
56
|
+
| Can I keep the answer? | `report` |
|
|
57
|
+
| What did the kernel see for a few seconds? | `watch --pid` |
|
|
58
|
+
|
|
59
|
+
`status` and `check` read a process table. That is `/proc` on the node. `watch` needs `bpftrace` and root, and it only traces the one pid you name. The probe prints `exec`, `connect`, `open`, and `chmod`. It does not follow a child after the exec, and it prints IPv4 connections. IPv6 peers still show up in `status`.
|
|
60
|
+
|
|
61
|
+
## Examples
|
|
62
|
+
|
|
63
|
+
A saved process table and a saved trace are in `examples/`. Neither needs root.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
chaintrace status --proc examples/geth --pid 4242
|
|
67
|
+
chaintrace check --proc examples/geth --pid 4242
|
|
68
|
+
chaintrace check --trace examples/compromise.trace
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
On the node:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
chaintrace status --name geth
|
|
75
|
+
chaintrace check --name lighthouse
|
|
76
|
+
chaintrace check --pid 4242 --client teku
|
|
77
|
+
chaintrace baseline --name geth
|
|
78
|
+
chaintrace report --name geth
|
|
79
|
+
chaintrace watch --pid 4242 --seconds 10 > /tmp/node.trace
|
|
80
|
+
chaintrace check --trace /tmp/node.trace
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`--client` selects a profile when the process name is generic, such as `java` for Teku. `--json` prints one document. `report` writes `chaintrace-report.md`.
|
|
84
|
+
|
|
85
|
+
A password-style argument is printed as `[redacted]`. A path, including a JWT file path, stays visible. File contents are never read.
|
|
86
|
+
|
|
87
|
+
## What a connection means
|
|
88
|
+
|
|
89
|
+
| Line | Meaning |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `listen` | The process is waiting on this address. |
|
|
92
|
+
| `local` | The other end is on this machine, such as the engine API. |
|
|
93
|
+
| `peer` | The local or remote port is a peer port for this client. |
|
|
94
|
+
| `expected` | The remote port is normal for this client, such as a builder relay on 443. |
|
|
95
|
+
| `remote` | The process has no profile, so the destination is shown and not judged. |
|
|
96
|
+
| `unknown` | This profiled client connected somewhere that is not a peer, a loopback address, or an expected port. |
|
|
97
|
+
| `metadata` | The destination is a cloud metadata address. |
|
|
98
|
+
|
|
99
|
+
Profiles cover the execution, consensus, and builder clients these tools already talk about: Geth, Reth, Nethermind, Erigon, Besu, Lighthouse, Prysm, Teku, Nimbus, Lodestar, MEV-boost, op-node, and op-geth. Any other process is still listed. Its public connections are `remote` rather than `unknown`.
|
|
100
|
+
|
|
101
|
+
## Findings
|
|
102
|
+
|
|
103
|
+
Exit 0 means there is no warning or critical finding. An `info` line still exits 0. Exit 1 means a warning. Exit 2 means a critical finding. Exit 3 means the process or the trace could not be read.
|
|
104
|
+
|
|
105
|
+
| Finding | Severity | When |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `sensitive-file` | info, or warning if another program has it open | A JWT, keystore, node key, or mnemonic path is open. |
|
|
108
|
+
| `unknown-destination` | info | An unknown destination, and no baseline has been saved. |
|
|
109
|
+
| `new-destination` | warning | An unknown destination that was not in the baseline. |
|
|
110
|
+
| `new-listener` | warning | A listen address that was not in the baseline. |
|
|
111
|
+
| `binary-changed` | warning | The executable path changed since the baseline. |
|
|
112
|
+
| `deleted-binary` | warning | The executable path is marked deleted. |
|
|
113
|
+
| `exposed-port` | warning | The HTTP or engine port is listening outside loopback. |
|
|
114
|
+
| `suspicious-port` | warning | An outbound connection to a shell or file-share port. |
|
|
115
|
+
| `traffic-anomaly` | warning | Five or more distinct unknown destinations. |
|
|
116
|
+
| `unexpected-exec` | warning, or critical for a shell or downloader | The process executed something other than itself. |
|
|
117
|
+
| `permission-change` | warning, or critical if the mode is world-writable | `chmod` on a path. |
|
|
118
|
+
| `shell-child` | critical | A child is a shell, downloader, or interpreter. |
|
|
119
|
+
| `metadata-destination` | critical | A connection to a cloud metadata address. |
|
|
120
|
+
| `persistence` | critical | An open path under cron, systemd, sudoers, preload, or authorized keys. |
|
|
121
|
+
|
|
122
|
+
`baseline` stores the executable, the listen addresses, and the unknown destinations in `~/.chaintrace/baseline.json`, or under `CHAINTRACE_HOME`. The file is readable only by its owner. One client name has one baseline. `--no-baseline` ignores it.
|
|
123
|
+
|
|
124
|
+
## Kernel probe
|
|
125
|
+
|
|
126
|
+
`watch` writes a bpftrace program and runs it. The program is read-only: exec, connect, open, and chmod for that pid, then it exits. A saved probe file is the same text `check --trace` reads. A JSON line per event works too.
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
exec 4242 geth /bin/bash
|
|
130
|
+
connect 4242 geth 169.254.169.254 80
|
|
131
|
+
open 4242 geth /etc/ld.so.preload
|
|
132
|
+
chmod 4242 geth /usr/bin/geth 511
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Run `watch` only on a host you administer. It does not survive past `--seconds`, which is at most 300.
|
|
136
|
+
|
|
137
|
+
## What it does not do
|
|
138
|
+
|
|
139
|
+
ChainTrace does not call a node RPC, restart a client, or decide whether a release is safe. It does not read key material. A report can include paths and destination addresses, which is the observation.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# ChainTrace
|
|
2
|
+
|
|
3
|
+
ChainTrace answers one question: **what is my node doing internally?**
|
|
4
|
+
|
|
5
|
+
It reads the process, the files it has open, and the connections it holds. When you ask, it also attaches a short read-only kernel probe. A finding is printed when that behavior does not belong on a node.
|
|
6
|
+
|
|
7
|
+
It does not stop the process, read a file, open a connection, or store a key.
|
|
8
|
+
|
|
9
|
+
ValidatorPulse raises an alert. ChainDiff decides whether an upgrade is safe. chainlog-ai explains a failure from the logs. ChainTrace is the view of the process itself.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install chaintrace
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Python 3.11 or newer. The package is published from a GitHub release to [PyPI](https://pypi.org/project/chaintrace/).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
chaintrace --version
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Which command
|
|
24
|
+
|
|
25
|
+
| Question | Command |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| What is this process doing? | `status --pid` or `status --name` |
|
|
28
|
+
| Is any of that suspicious? | `check` |
|
|
29
|
+
| What should the next check compare against? | `baseline` |
|
|
30
|
+
| Can I keep the answer? | `report` |
|
|
31
|
+
| What did the kernel see for a few seconds? | `watch --pid` |
|
|
32
|
+
|
|
33
|
+
`status` and `check` read a process table. That is `/proc` on the node. `watch` needs `bpftrace` and root, and it only traces the one pid you name. The probe prints `exec`, `connect`, `open`, and `chmod`. It does not follow a child after the exec, and it prints IPv4 connections. IPv6 peers still show up in `status`.
|
|
34
|
+
|
|
35
|
+
## Examples
|
|
36
|
+
|
|
37
|
+
A saved process table and a saved trace are in `examples/`. Neither needs root.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
chaintrace status --proc examples/geth --pid 4242
|
|
41
|
+
chaintrace check --proc examples/geth --pid 4242
|
|
42
|
+
chaintrace check --trace examples/compromise.trace
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
On the node:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
chaintrace status --name geth
|
|
49
|
+
chaintrace check --name lighthouse
|
|
50
|
+
chaintrace check --pid 4242 --client teku
|
|
51
|
+
chaintrace baseline --name geth
|
|
52
|
+
chaintrace report --name geth
|
|
53
|
+
chaintrace watch --pid 4242 --seconds 10 > /tmp/node.trace
|
|
54
|
+
chaintrace check --trace /tmp/node.trace
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`--client` selects a profile when the process name is generic, such as `java` for Teku. `--json` prints one document. `report` writes `chaintrace-report.md`.
|
|
58
|
+
|
|
59
|
+
A password-style argument is printed as `[redacted]`. A path, including a JWT file path, stays visible. File contents are never read.
|
|
60
|
+
|
|
61
|
+
## What a connection means
|
|
62
|
+
|
|
63
|
+
| Line | Meaning |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `listen` | The process is waiting on this address. |
|
|
66
|
+
| `local` | The other end is on this machine, such as the engine API. |
|
|
67
|
+
| `peer` | The local or remote port is a peer port for this client. |
|
|
68
|
+
| `expected` | The remote port is normal for this client, such as a builder relay on 443. |
|
|
69
|
+
| `remote` | The process has no profile, so the destination is shown and not judged. |
|
|
70
|
+
| `unknown` | This profiled client connected somewhere that is not a peer, a loopback address, or an expected port. |
|
|
71
|
+
| `metadata` | The destination is a cloud metadata address. |
|
|
72
|
+
|
|
73
|
+
Profiles cover the execution, consensus, and builder clients these tools already talk about: Geth, Reth, Nethermind, Erigon, Besu, Lighthouse, Prysm, Teku, Nimbus, Lodestar, MEV-boost, op-node, and op-geth. Any other process is still listed. Its public connections are `remote` rather than `unknown`.
|
|
74
|
+
|
|
75
|
+
## Findings
|
|
76
|
+
|
|
77
|
+
Exit 0 means there is no warning or critical finding. An `info` line still exits 0. Exit 1 means a warning. Exit 2 means a critical finding. Exit 3 means the process or the trace could not be read.
|
|
78
|
+
|
|
79
|
+
| Finding | Severity | When |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `sensitive-file` | info, or warning if another program has it open | A JWT, keystore, node key, or mnemonic path is open. |
|
|
82
|
+
| `unknown-destination` | info | An unknown destination, and no baseline has been saved. |
|
|
83
|
+
| `new-destination` | warning | An unknown destination that was not in the baseline. |
|
|
84
|
+
| `new-listener` | warning | A listen address that was not in the baseline. |
|
|
85
|
+
| `binary-changed` | warning | The executable path changed since the baseline. |
|
|
86
|
+
| `deleted-binary` | warning | The executable path is marked deleted. |
|
|
87
|
+
| `exposed-port` | warning | The HTTP or engine port is listening outside loopback. |
|
|
88
|
+
| `suspicious-port` | warning | An outbound connection to a shell or file-share port. |
|
|
89
|
+
| `traffic-anomaly` | warning | Five or more distinct unknown destinations. |
|
|
90
|
+
| `unexpected-exec` | warning, or critical for a shell or downloader | The process executed something other than itself. |
|
|
91
|
+
| `permission-change` | warning, or critical if the mode is world-writable | `chmod` on a path. |
|
|
92
|
+
| `shell-child` | critical | A child is a shell, downloader, or interpreter. |
|
|
93
|
+
| `metadata-destination` | critical | A connection to a cloud metadata address. |
|
|
94
|
+
| `persistence` | critical | An open path under cron, systemd, sudoers, preload, or authorized keys. |
|
|
95
|
+
|
|
96
|
+
`baseline` stores the executable, the listen addresses, and the unknown destinations in `~/.chaintrace/baseline.json`, or under `CHAINTRACE_HOME`. The file is readable only by its owner. One client name has one baseline. `--no-baseline` ignores it.
|
|
97
|
+
|
|
98
|
+
## Kernel probe
|
|
99
|
+
|
|
100
|
+
`watch` writes a bpftrace program and runs it. The program is read-only: exec, connect, open, and chmod for that pid, then it exits. A saved probe file is the same text `check --trace` reads. A JSON line per event works too.
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
exec 4242 geth /bin/bash
|
|
104
|
+
connect 4242 geth 169.254.169.254 80
|
|
105
|
+
open 4242 geth /etc/ld.so.preload
|
|
106
|
+
chmod 4242 geth /usr/bin/geth 511
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Run `watch` only on a host you administer. It does not survive past `--seconds`, which is at most 300.
|
|
110
|
+
|
|
111
|
+
## What it does not do
|
|
112
|
+
|
|
113
|
+
ChainTrace does not call a node RPC, restart a client, or decide whether a release is safe. It does not read key material. A report can include paths and destination addresses, which is the observation.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "chaintrace"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "See what a blockchain node is doing, from the process table and a short kernel trace."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
keywords = [
|
|
10
|
+
"ebpf",
|
|
11
|
+
"bpftrace",
|
|
12
|
+
"ethereum",
|
|
13
|
+
"geth",
|
|
14
|
+
"lighthouse",
|
|
15
|
+
"security",
|
|
16
|
+
"node",
|
|
17
|
+
]
|
|
18
|
+
authors = [{ name = "ehsanhajian" }]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: System Administrators",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Security",
|
|
28
|
+
"Topic :: System :: Networking :: Monitoring",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=8"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/ehsanhajian/chaintrace"
|
|
37
|
+
Issues = "https://github.com/ehsanhajian/chaintrace/issues"
|
|
38
|
+
Changelog = "https://github.com/ehsanhajian/chaintrace/releases"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
chaintrace = "chaintrace.cli:main"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["setuptools>=84"]
|
|
45
|
+
build-backend = "setuptools.build_meta"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.package-data]
|
|
51
|
+
chaintrace = ["clients.json"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
pythonpath = ["src"]
|
|
@@ -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)
|
|
@@ -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
|
+
}
|