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/__init__.py
ADDED
chaintrace/__main__.py
ADDED
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)
|
chaintrace/clients.json
ADDED
|
@@ -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
|