sysgraph 0.0.11__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.
- sysgraph/__init__.py +1 -0
- sysgraph/__main__.py +3 -0
- sysgraph/app.py +134 -0
- sysgraph/discovery.py +530 -0
- sysgraph/dist/assets/index-DFF8eDbX.js +735 -0
- sysgraph/dist/index.html +181 -0
- sysgraph/graph.py +104 -0
- sysgraph/main.py +47 -0
- sysgraph/model.py +114 -0
- sysgraph-0.0.11.dist-info/METADATA +111 -0
- sysgraph-0.0.11.dist-info/RECORD +15 -0
- sysgraph-0.0.11.dist-info/WHEEL +5 -0
- sysgraph-0.0.11.dist-info/entry_points.txt +2 -0
- sysgraph-0.0.11.dist-info/licenses/LICENSE +21 -0
- sysgraph-0.0.11.dist-info/top_level.txt +1 -0
sysgraph/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.11"
|
sysgraph/__main__.py
ADDED
sysgraph/app.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import coloredlogs
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
12
|
+
from fastapi.responses import FileResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from sysgraph.discovery import build_graph
|
|
17
|
+
|
|
18
|
+
LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@asynccontextmanager
|
|
22
|
+
async def lifespan(app):
|
|
23
|
+
coloredlogs.install()
|
|
24
|
+
logging.info(f"init for process with PID {os.getpid()}")
|
|
25
|
+
yield
|
|
26
|
+
logging.info(f"shutdown for process with PID {os.getpid()}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
app = FastAPI(title="sysgraph API", version="0.1.0", lifespan=lifespan)
|
|
30
|
+
app.add_middleware(GZipMiddleware, minimum_size=500)
|
|
31
|
+
|
|
32
|
+
# Vite build output — run scripts/build-ui.sh to produce it.
|
|
33
|
+
_dist_dir = Path(__file__).parent / "dist"
|
|
34
|
+
|
|
35
|
+
if not _dist_dir.is_dir():
|
|
36
|
+
LOGGER.warning(
|
|
37
|
+
"dist/ not found — frontend will not be served. "
|
|
38
|
+
"Run scripts/build-ui.sh to produce a production build."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Root route serves the SPA index — defined before the catch-all mount.
|
|
43
|
+
@app.get("/", include_in_schema=False)
|
|
44
|
+
def index():
|
|
45
|
+
index_path = _dist_dir / "index.html"
|
|
46
|
+
return FileResponse(index_path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GraphNodeSchema(BaseModel):
|
|
50
|
+
id: str
|
|
51
|
+
type: str
|
|
52
|
+
properties: dict[str, Any]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GraphEdgeSchema(BaseModel):
|
|
56
|
+
id: str
|
|
57
|
+
source_id: str
|
|
58
|
+
target_id: str
|
|
59
|
+
type: str
|
|
60
|
+
properties: dict[str, Any]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GraphSchema(BaseModel):
|
|
64
|
+
nodes: list[GraphNodeSchema]
|
|
65
|
+
edges: list[GraphEdgeSchema]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.get("/api/health")
|
|
69
|
+
def health():
|
|
70
|
+
return {"status": "ok"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.get("/api/graph", response_model=GraphSchema)
|
|
74
|
+
def get_graph() -> GraphSchema:
|
|
75
|
+
graph = build_graph()
|
|
76
|
+
graph_dict = graph.as_dict()
|
|
77
|
+
|
|
78
|
+
LOGGER.debug(graph_dict)
|
|
79
|
+
|
|
80
|
+
return GraphSchema(
|
|
81
|
+
nodes=[
|
|
82
|
+
GraphNodeSchema(
|
|
83
|
+
id=node["id"], type=node["type"], properties=node["properties"]
|
|
84
|
+
)
|
|
85
|
+
for node in graph_dict["nodes"]
|
|
86
|
+
],
|
|
87
|
+
edges=[
|
|
88
|
+
GraphEdgeSchema(
|
|
89
|
+
id=edge["id"],
|
|
90
|
+
source_id=edge["source_id"],
|
|
91
|
+
target_id=edge["target_id"],
|
|
92
|
+
type=edge["type"],
|
|
93
|
+
properties=edge["properties"],
|
|
94
|
+
)
|
|
95
|
+
for edge in graph_dict["edges"]
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Serve built assets (JS/CSS bundles, Shoelace icons, etc.) from the same
|
|
101
|
+
# directory that provides index.html. This catch-all mount MUST come after
|
|
102
|
+
# all explicit routes so that /api/* and / are matched first.
|
|
103
|
+
if _dist_dir.is_dir():
|
|
104
|
+
app.mount(
|
|
105
|
+
"/", StaticFiles(directory=str(_dist_dir)), name="static"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
import argparse
|
|
111
|
+
|
|
112
|
+
import uvicorn
|
|
113
|
+
|
|
114
|
+
parser = argparse.ArgumentParser(description="sysgraph server")
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"-p",
|
|
117
|
+
"--port",
|
|
118
|
+
type=int,
|
|
119
|
+
default=int(os.environ.get("PORT", 8000)),
|
|
120
|
+
help="port to listen on (default: 8000, or PORT env var)",
|
|
121
|
+
)
|
|
122
|
+
args = parser.parse_args()
|
|
123
|
+
|
|
124
|
+
uvicorn.run(
|
|
125
|
+
"sysgraph.app:app",
|
|
126
|
+
host="0.0.0.0",
|
|
127
|
+
port=args.port,
|
|
128
|
+
reload=True,
|
|
129
|
+
log_level="info",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
sysgraph/discovery.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from socket import AddressFamily
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
from sysgraph.graph import Graph, Node
|
|
13
|
+
from sysgraph.model import (
|
|
14
|
+
NetConnection,
|
|
15
|
+
Process,
|
|
16
|
+
ProcessOpenFile,
|
|
17
|
+
SocketAddress,
|
|
18
|
+
UnixDomainSocket,
|
|
19
|
+
UnixDomainSocketConnection,
|
|
20
|
+
UnixDomainSocketProcRef,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
LOGGER = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def discover_processes() -> list[Process]:
|
|
27
|
+
processes = []
|
|
28
|
+
|
|
29
|
+
attrs = [
|
|
30
|
+
"pid",
|
|
31
|
+
"ppid",
|
|
32
|
+
"username",
|
|
33
|
+
"cmdline",
|
|
34
|
+
"name",
|
|
35
|
+
"cpu_times",
|
|
36
|
+
"environ",
|
|
37
|
+
]
|
|
38
|
+
for proc in psutil.process_iter(attrs):
|
|
39
|
+
info = proc.info
|
|
40
|
+
p = Process(pid=info["pid"])
|
|
41
|
+
p.parent_pid = info["ppid"]
|
|
42
|
+
p.user = info["username"]
|
|
43
|
+
p.command = " ".join(info["cmdline"]) if info["cmdline"] else None
|
|
44
|
+
p.name = info.get("name")
|
|
45
|
+
|
|
46
|
+
cpu_times = info.get("cpu_times")
|
|
47
|
+
if cpu_times:
|
|
48
|
+
p.cpu_user = cpu_times.user
|
|
49
|
+
p.cpu_system = cpu_times.system
|
|
50
|
+
|
|
51
|
+
p.environment = info.get("environ")
|
|
52
|
+
|
|
53
|
+
processes.append(p)
|
|
54
|
+
|
|
55
|
+
return processes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def discover_unix_sockets() -> list[UnixDomainSocket]:
|
|
59
|
+
"""Discover Unix Domain Sockets as reported by kernel.
|
|
60
|
+
|
|
61
|
+
Note however that connection with UDS will have at least 2 records reported
|
|
62
|
+
with opposite local/peer inodes. They can be further groupped to
|
|
63
|
+
find connected pairs of processes.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
list[UnixDomainSocket]: _description_
|
|
67
|
+
"""
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["ss", "-xp"], capture_output=True, text=True, check=False
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
sockets = []
|
|
73
|
+
|
|
74
|
+
# example line:
|
|
75
|
+
# users:(("dbus-daemon",pid=1950,fd=12))
|
|
76
|
+
def parse_proccess(s: str) -> list[UnixDomainSocketProcRef]:
|
|
77
|
+
processes = []
|
|
78
|
+
|
|
79
|
+
for match in re.finditer(
|
|
80
|
+
r'\("(?P<name>[^"]+)",pid=(?P<pid>[0-9]+),fd=(?P<fd>[0-9]+)\)', s
|
|
81
|
+
):
|
|
82
|
+
ref = UnixDomainSocketProcRef(pid=int(match.group("pid")))
|
|
83
|
+
ref.name = match.group("name")
|
|
84
|
+
ref.fd = int(match.group("fd"))
|
|
85
|
+
processes.append(ref)
|
|
86
|
+
|
|
87
|
+
return processes
|
|
88
|
+
|
|
89
|
+
for line in result.stdout.splitlines()[1:]:
|
|
90
|
+
segments = line.split(" ")
|
|
91
|
+
segments = [s for s in segments if s]
|
|
92
|
+
|
|
93
|
+
uds = UnixDomainSocket(
|
|
94
|
+
local_inode=int(segments[5]),
|
|
95
|
+
peer_inode=int(segments[7]),
|
|
96
|
+
)
|
|
97
|
+
uds.local_path = segments[4]
|
|
98
|
+
uds.peer_path = segments[6]
|
|
99
|
+
|
|
100
|
+
if len(segments) > 8:
|
|
101
|
+
uds.processes = parse_proccess(segments[8])
|
|
102
|
+
|
|
103
|
+
uds.state = segments[1]
|
|
104
|
+
uds.uds_type = segments[0]
|
|
105
|
+
|
|
106
|
+
sockets.append(uds)
|
|
107
|
+
|
|
108
|
+
LOGGER.info(f"found {len(sockets)} UDS")
|
|
109
|
+
|
|
110
|
+
return sockets
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def discover_connected_uds(
|
|
114
|
+
sockets: list[UnixDomainSocket],
|
|
115
|
+
) -> list[UnixDomainSocketConnection]:
|
|
116
|
+
"""Discover connected UDS pairs from the list of discovered UDS sockets.
|
|
117
|
+
|
|
118
|
+
Each connection will have two UDS sockets with opposite local/peer inodes.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
sockets (list[UnixDomainSocket]): List of discovered UDS sockets.
|
|
122
|
+
Returns:
|
|
123
|
+
list[UnixDomainSocketConnection]: List of connected UDS pairs.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
inode_map = {}
|
|
127
|
+
for uds in sockets:
|
|
128
|
+
inode_map[(uds.local_inode, uds.peer_inode)] = uds
|
|
129
|
+
|
|
130
|
+
connections = []
|
|
131
|
+
visited = set()
|
|
132
|
+
|
|
133
|
+
for uds in sockets:
|
|
134
|
+
if (uds.peer_inode, uds.local_inode) in inode_map:
|
|
135
|
+
peer_uds = inode_map[(uds.peer_inode, uds.local_inode)]
|
|
136
|
+
if (uds.local_inode, uds.peer_inode) not in visited and (
|
|
137
|
+
uds.peer_inode,
|
|
138
|
+
uds.local_inode,
|
|
139
|
+
) not in visited:
|
|
140
|
+
connection = UnixDomainSocketConnection(
|
|
141
|
+
socket1=uds, socket2=peer_uds
|
|
142
|
+
)
|
|
143
|
+
connections.append(connection)
|
|
144
|
+
visited.add((uds.local_inode, uds.peer_inode))
|
|
145
|
+
visited.add((uds.peer_inode, uds.local_inode))
|
|
146
|
+
|
|
147
|
+
LOGGER.info(f"found {len(connections)} connected UDS pairs")
|
|
148
|
+
return connections
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_processes_open_files() -> dict[int, list[ProcessOpenFile]]:
|
|
152
|
+
"""Read open file descriptors directly from /proc instead of spawning lsof."""
|
|
153
|
+
result_map: dict[int, list[ProcessOpenFile]] = defaultdict(list)
|
|
154
|
+
proc_path = Path("/proc")
|
|
155
|
+
|
|
156
|
+
for pid_dir in proc_path.iterdir():
|
|
157
|
+
if not pid_dir.name.isdigit():
|
|
158
|
+
continue
|
|
159
|
+
pid = int(pid_dir.name)
|
|
160
|
+
fd_dir = pid_dir / "fd"
|
|
161
|
+
try:
|
|
162
|
+
fd_entries = list(fd_dir.iterdir())
|
|
163
|
+
except (PermissionError, OSError):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
for fd_entry in fd_entries:
|
|
167
|
+
if not fd_entry.name.isdigit():
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
target = os.readlink(fd_entry)
|
|
171
|
+
except (PermissionError, OSError):
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# only collect pipes (the only type used downstream)
|
|
175
|
+
if not target.startswith("pipe:"):
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
fd_num = int(fd_entry.name)
|
|
179
|
+
# extract inode from "pipe:[12345]"
|
|
180
|
+
inode = target[6:-1]
|
|
181
|
+
|
|
182
|
+
# read mode from /proc/[pid]/fdinfo/[fd]
|
|
183
|
+
mode = None
|
|
184
|
+
try:
|
|
185
|
+
fdinfo = (pid_dir / "fdinfo" / fd_entry.name).read_text()
|
|
186
|
+
for line in fdinfo.splitlines():
|
|
187
|
+
if line.startswith("flags:"):
|
|
188
|
+
flags = int(line.split(":", 1)[1].strip(), 8)
|
|
189
|
+
access = flags & 0o3
|
|
190
|
+
mode = "r" if access == 0o0 else "w"
|
|
191
|
+
break
|
|
192
|
+
except (PermissionError, OSError):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
open_file = ProcessOpenFile(fd_num, "FIFO", target)
|
|
196
|
+
open_file.node = inode
|
|
197
|
+
open_file.mode = mode
|
|
198
|
+
result_map[pid].append(open_file)
|
|
199
|
+
|
|
200
|
+
return result_map
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_net_connections(pid: int) -> list[NetConnection]:
|
|
204
|
+
proc = psutil.Process(pid)
|
|
205
|
+
connections: list[NetConnection] = []
|
|
206
|
+
for pcon in proc.net_connections(kind="all"):
|
|
207
|
+
if pcon.family not in (AddressFamily.AF_INET, AddressFamily.AF_INET6):
|
|
208
|
+
continue
|
|
209
|
+
connections.append(
|
|
210
|
+
NetConnection(
|
|
211
|
+
pid=pid,
|
|
212
|
+
local_address=SocketAddress(pcon.laddr.ip, pcon.laddr.port),
|
|
213
|
+
remote_address=SocketAddress(pcon.raddr.ip, pcon.raddr.port)
|
|
214
|
+
if pcon.raddr
|
|
215
|
+
else None,
|
|
216
|
+
socket_type=pcon.type.name,
|
|
217
|
+
state=pcon.status,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
return connections
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def get_all_net_connections() -> dict[int, list[NetConnection]]:
|
|
224
|
+
"""Fetch all network connections system-wide in a single call."""
|
|
225
|
+
result: dict[int, list[NetConnection]] = defaultdict(list)
|
|
226
|
+
for pcon in psutil.net_connections(kind="inet"):
|
|
227
|
+
if pcon.pid is None:
|
|
228
|
+
continue
|
|
229
|
+
result[pcon.pid].append(
|
|
230
|
+
NetConnection(
|
|
231
|
+
pid=pcon.pid,
|
|
232
|
+
local_address=SocketAddress(pcon.laddr.ip, pcon.laddr.port),
|
|
233
|
+
remote_address=SocketAddress(pcon.raddr.ip, pcon.raddr.port)
|
|
234
|
+
if pcon.raddr
|
|
235
|
+
else None,
|
|
236
|
+
socket_type=pcon.type.name,
|
|
237
|
+
state=pcon.status,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def build_graph(discover_uds_connectivity: bool = True) -> Graph:
|
|
244
|
+
graph = Graph()
|
|
245
|
+
|
|
246
|
+
pid_to_node: dict[int, Node] = {}
|
|
247
|
+
|
|
248
|
+
# run independent discovery steps in parallel (all I/O-bound)
|
|
249
|
+
with ThreadPoolExecutor() as executor:
|
|
250
|
+
processes_future = executor.submit(discover_processes)
|
|
251
|
+
uds_future = executor.submit(discover_unix_sockets)
|
|
252
|
+
open_files_future = executor.submit(get_processes_open_files)
|
|
253
|
+
net_connections_future = executor.submit(get_all_net_connections)
|
|
254
|
+
|
|
255
|
+
processes = processes_future.result()
|
|
256
|
+
uds = uds_future.result()
|
|
257
|
+
open_files_map = open_files_future.result()
|
|
258
|
+
all_net_connections = net_connections_future.result()
|
|
259
|
+
|
|
260
|
+
for proc in processes:
|
|
261
|
+
proc_node_id = f"process::{proc.pid}"
|
|
262
|
+
node = graph.add_node(
|
|
263
|
+
proc_node_id,
|
|
264
|
+
"process",
|
|
265
|
+
properties={
|
|
266
|
+
"pid": proc.pid,
|
|
267
|
+
"command": proc.command,
|
|
268
|
+
"user": proc.user,
|
|
269
|
+
"name": proc.name,
|
|
270
|
+
"cpu_user": proc.cpu_user,
|
|
271
|
+
"cpu_system": proc.cpu_system,
|
|
272
|
+
"environment": proc.environment,
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
pid_to_node[proc.pid] = node
|
|
276
|
+
|
|
277
|
+
# add parent-child relationships
|
|
278
|
+
for proc in processes:
|
|
279
|
+
proc_node = pid_to_node[proc.pid]
|
|
280
|
+
parent_proc_node = None
|
|
281
|
+
if proc.parent_pid is not None:
|
|
282
|
+
parent_proc_node = pid_to_node.get(proc.parent_pid)
|
|
283
|
+
if parent_proc_node is not None:
|
|
284
|
+
_ = graph.add_edge(
|
|
285
|
+
source_id=parent_proc_node.id,
|
|
286
|
+
target_id=proc_node.id,
|
|
287
|
+
rel_type="child_process",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
uds_node_map: dict[int, Node] = {}
|
|
291
|
+
|
|
292
|
+
def ensure_uds_node(uds_socket: UnixDomainSocket) -> Node:
|
|
293
|
+
inode = uds_socket.local_inode
|
|
294
|
+
|
|
295
|
+
if inode in uds_node_map:
|
|
296
|
+
return uds_node_map[inode]
|
|
297
|
+
|
|
298
|
+
node_id = f"uds::{inode}"
|
|
299
|
+
|
|
300
|
+
if uds_socket.local_path:
|
|
301
|
+
if uds_socket.local_path == "*":
|
|
302
|
+
label = f"uds:[{inode}]"
|
|
303
|
+
else:
|
|
304
|
+
label = uds_socket.local_path
|
|
305
|
+
|
|
306
|
+
# strip trailing @ if present (abstract unix socket)
|
|
307
|
+
label = label.rstrip("@")
|
|
308
|
+
else: # no local path
|
|
309
|
+
label = f"uds:[{inode}]"
|
|
310
|
+
|
|
311
|
+
node = graph.add_node(
|
|
312
|
+
node_id,
|
|
313
|
+
"uds",
|
|
314
|
+
properties={
|
|
315
|
+
"label": label,
|
|
316
|
+
"local_inode": uds_socket.local_inode,
|
|
317
|
+
"local_address": uds_socket.local_path,
|
|
318
|
+
"peer_inode": uds_socket.peer_inode,
|
|
319
|
+
"peer_address": uds_socket.peer_path,
|
|
320
|
+
"state": uds_socket.state,
|
|
321
|
+
"uds_type": uds_socket.uds_type,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
uds_node_map[inode] = node
|
|
325
|
+
return node
|
|
326
|
+
|
|
327
|
+
# track which process→uds edges have been added to avoid duplicates
|
|
328
|
+
uds_process_edges: set[tuple[int, int]] = set()
|
|
329
|
+
|
|
330
|
+
# create UDS nodes for ALL discovered sockets and connect them
|
|
331
|
+
# to their processes
|
|
332
|
+
for uds_socket in uds:
|
|
333
|
+
uds_node = ensure_uds_node(uds_socket)
|
|
334
|
+
for p_ref in uds_socket.processes:
|
|
335
|
+
edge_key = (p_ref.pid, uds_socket.local_inode)
|
|
336
|
+
if p_ref.pid in pid_to_node and edge_key not in uds_process_edges:
|
|
337
|
+
uds_process_edges.add(edge_key)
|
|
338
|
+
graph.add_edge(
|
|
339
|
+
source_id=pid_to_node[p_ref.pid].id,
|
|
340
|
+
target_id=uds_node.id,
|
|
341
|
+
rel_type="uds",
|
|
342
|
+
properties={
|
|
343
|
+
"label": f"uds (fd={p_ref.fd})",
|
|
344
|
+
"fd": p_ref.fd,
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# optionally discover connected UDS pairs and add connection edges
|
|
349
|
+
if discover_uds_connectivity:
|
|
350
|
+
for con in discover_connected_uds(uds):
|
|
351
|
+
uds_node1 = ensure_uds_node(con.socket1)
|
|
352
|
+
uds_node2 = ensure_uds_node(con.socket2)
|
|
353
|
+
|
|
354
|
+
graph.add_edge(
|
|
355
|
+
uds_node1.id,
|
|
356
|
+
uds_node2.id,
|
|
357
|
+
"uds_connection",
|
|
358
|
+
properties={
|
|
359
|
+
"directional": False,
|
|
360
|
+
"dashed": True,
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
pipe_node_to_node = {}
|
|
365
|
+
|
|
366
|
+
def ensure_pipe_node(file_node):
|
|
367
|
+
if file_node not in pipe_node_to_node:
|
|
368
|
+
pipe_node_to_node[file_node] = graph.add_node(
|
|
369
|
+
f"pipe::{file_node}",
|
|
370
|
+
"pipe",
|
|
371
|
+
properties={
|
|
372
|
+
"label": f"pipe:[{file_node}]",
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
return pipe_node_to_node[file_node]
|
|
376
|
+
|
|
377
|
+
for pid, files in open_files_map.items():
|
|
378
|
+
if pid not in pid_to_node:
|
|
379
|
+
continue
|
|
380
|
+
process_node = pid_to_node[pid]
|
|
381
|
+
for file in files:
|
|
382
|
+
# so far just show the pipes
|
|
383
|
+
if file.file_type != "FIFO":
|
|
384
|
+
continue
|
|
385
|
+
node = ensure_pipe_node(file.node)
|
|
386
|
+
|
|
387
|
+
if file.mode == "r":
|
|
388
|
+
_ = graph.add_edge(
|
|
389
|
+
source_id=node.id,
|
|
390
|
+
target_id=process_node.id,
|
|
391
|
+
rel_type="pipe",
|
|
392
|
+
properties={
|
|
393
|
+
"label": f"pipe (fd={file.fd})",
|
|
394
|
+
"fd": file.fd,
|
|
395
|
+
"mode": file.mode,
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
_ = graph.add_edge(
|
|
400
|
+
source_id=process_node.id,
|
|
401
|
+
target_id=node.id,
|
|
402
|
+
rel_type="pipe",
|
|
403
|
+
properties={
|
|
404
|
+
"label": f"pipe (fd={file.fd})",
|
|
405
|
+
"fd": file.fd,
|
|
406
|
+
"mode": file.mode,
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
socket_to_pids: dict[tuple[SocketAddress, str], list[int]] = {}
|
|
411
|
+
socket_to_node: dict[tuple[SocketAddress, str], Node] = {}
|
|
412
|
+
|
|
413
|
+
def is_ipv6(address: str) -> bool:
|
|
414
|
+
return ":" in address
|
|
415
|
+
|
|
416
|
+
def ensure_socket(address: SocketAddress, socket_type, state: str):
|
|
417
|
+
key = (address, socket_type)
|
|
418
|
+
if key in socket_to_node:
|
|
419
|
+
return socket_to_node[key]
|
|
420
|
+
socket_node_id = f"socket::{address}::{socket_type}"
|
|
421
|
+
socket_node = graph.add_node(socket_node_id, "socket")
|
|
422
|
+
|
|
423
|
+
simple_socket_type = {
|
|
424
|
+
"SOCK_DGRAM": "UDP",
|
|
425
|
+
"SOCK_STREAM": "TCP",
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
simple_type = simple_socket_type.get(socket_type, socket_type)
|
|
429
|
+
|
|
430
|
+
if is_ipv6(address.ip):
|
|
431
|
+
socket_node.properties["label"] = (
|
|
432
|
+
f"[{address.ip}]:{address.port} ({simple_type})"
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
socket_node.properties["label"] = (
|
|
436
|
+
f"{address.ip}:{address.port} ({simple_type})"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
socket_node.properties["state"] = state
|
|
440
|
+
socket_node.properties["socket_type"] = socket_type
|
|
441
|
+
|
|
442
|
+
socket_to_node[key] = socket_node
|
|
443
|
+
return socket_node
|
|
444
|
+
|
|
445
|
+
connected_sockets = set()
|
|
446
|
+
|
|
447
|
+
def ensure_sockets_connected(socket1: Node, socket2: Node):
|
|
448
|
+
key = tuple(sorted([socket1.id, socket2.id]))
|
|
449
|
+
if key in connected_sockets:
|
|
450
|
+
return
|
|
451
|
+
connected_sockets.add(key)
|
|
452
|
+
_ = graph.add_edge(
|
|
453
|
+
socket1.id,
|
|
454
|
+
socket2.id,
|
|
455
|
+
"socket_connection",
|
|
456
|
+
{
|
|
457
|
+
"directional": False,
|
|
458
|
+
"dashed": True,
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
for proc in processes:
|
|
463
|
+
net_connections = all_net_connections.get(proc.pid, [])
|
|
464
|
+
if not net_connections:
|
|
465
|
+
continue
|
|
466
|
+
proc_node = pid_to_node.get(proc.pid)
|
|
467
|
+
if proc_node is None:
|
|
468
|
+
continue
|
|
469
|
+
|
|
470
|
+
for net_con in net_connections:
|
|
471
|
+
socket_id = (net_con.local_address, net_con.socket_type)
|
|
472
|
+
|
|
473
|
+
# ensure socket in graph
|
|
474
|
+
local_socket = ensure_socket(
|
|
475
|
+
net_con.local_address,
|
|
476
|
+
net_con.socket_type,
|
|
477
|
+
net_con.state,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# process connection
|
|
481
|
+
if proc.pid not in socket_to_pids.get(socket_id, []):
|
|
482
|
+
socket_to_pids[socket_id] = socket_to_pids.get(
|
|
483
|
+
socket_id, []
|
|
484
|
+
) + [proc.pid]
|
|
485
|
+
graph.add_edge(
|
|
486
|
+
source_id=proc_node.id,
|
|
487
|
+
target_id=local_socket.id,
|
|
488
|
+
rel_type="socket",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if net_con.remote_address:
|
|
492
|
+
remote_socket = ensure_socket(
|
|
493
|
+
net_con.remote_address,
|
|
494
|
+
net_con.socket_type,
|
|
495
|
+
net_con.state,
|
|
496
|
+
)
|
|
497
|
+
ensure_sockets_connected(local_socket, remote_socket)
|
|
498
|
+
|
|
499
|
+
# post-process all sockets which are NOT connected to any process -- these
|
|
500
|
+
# are remote endpoints, group the by IP address
|
|
501
|
+
external_ip_to_node = {}
|
|
502
|
+
|
|
503
|
+
def ensure_external_ip(address):
|
|
504
|
+
if address not in external_ip_to_node:
|
|
505
|
+
external_ip_to_node[address] = graph.add_node(
|
|
506
|
+
f"external_ip::{address}",
|
|
507
|
+
"external_ip",
|
|
508
|
+
{
|
|
509
|
+
"label": address,
|
|
510
|
+
},
|
|
511
|
+
)
|
|
512
|
+
return external_ip_to_node[address]
|
|
513
|
+
|
|
514
|
+
for socket, socket_node in socket_to_node.items():
|
|
515
|
+
pids = socket_to_pids.get(socket)
|
|
516
|
+
|
|
517
|
+
if pids:
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
external_ip = socket[0].ip
|
|
521
|
+
external_ip_node = ensure_external_ip(external_ip)
|
|
522
|
+
|
|
523
|
+
# add connection
|
|
524
|
+
graph.add_edge(
|
|
525
|
+
external_ip_node.id,
|
|
526
|
+
socket_node.id,
|
|
527
|
+
"external_socket",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
return graph
|