From 9b25240d7e10ca8ef8664844f0e4f4c2bb15ef33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Wed, 24 Jun 2026 16:36:15 -0400 Subject: [PATCH] tracer: in-kernel agent request graph; drop /proc fallback Attribute gpg-agent/ssh-agent-mediated touches to the real client from inside the kernel: mark client sockets at unix_accept, then on unix_stream_recvmsg record the requesting peer per agent kind (exact, since the key serializes touches). main.go resolves via tracer.AgentClientPID, so the agentpeer /proc UNIX_DIAG scan and its cap_sys_ptrace/cap_dac_read_search are removed. Also pin CGO_ENABLED=0 so the capped (non-dumpable) binary keeps a readable auxv for cilium's kernel-version detection. Classifier/proctree gain shell-arg normalization so resolved trees name the tool, not the interpreter. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 11 +- README.md | 5 +- internal/agentpeer/agentpeer.go | 214 ------------------------- internal/classifier/classifier.go | 77 ++++++++- internal/classifier/classifier_test.go | 55 +++++++ internal/proctree/proctree.go | 5 +- internal/tracer/tracer.bpf.c | 173 ++++++++++++++++++++ internal/tracer/tracer.go | 77 ++++++++- main.go | 37 ++++- main_test.go | 2 +- packaging/postinstall.sh | 2 + 11 files changed, 418 insertions(+), 240 deletions(-) delete mode 100644 internal/agentpeer/agentpeer.go create mode 100644 internal/classifier/classifier_test.go diff --git a/Makefile b/Makefile index 1ebc7a3..683b62d 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,9 @@ BPF_SRC := internal/tracer/tracer.bpf.c BPF_OBJ := internal/tracer/tracer.bpf.o BIN := whence-touche GO_SRCS := $(shell find . -name '*.go') go.mod go.sum +# cap_bpf + cap_perfmon + cap_sys_admin are all that's needed: they load and +# attach the eBPF probes. Agent-mediated touches (gpg-agent, ssh-agent) are +# resolved in-kernel by the request graph, so no /proc-scanning caps are required. CAPS := cap_bpf,cap_perfmon,cap_sys_admin+ep # Debian/Ubuntu keep the arch-specific uapi headers under a multiarch @@ -24,8 +27,14 @@ build: $(BIN) $(BPF_OBJ): $(BPF_SRC) $(BPF_CLANG) $(BPF_CFLAGS) -c $< -o $@ +# CGO_ENABLED=0 builds a pure-Go static binary, and that matters here: a cgo +# binary can't read the ELF auxv off the stack and falls back to /proc/self/auxv, +# which the file caps below make unreadable (the process is non-dumpable). Without +# auxv, cilium/ebpf can't detect the kernel version and the tracer fails to load. +# The Nix shell and goreleaser already set this; pin it so a bare `make build` +# (outside the Nix shell, with a C compiler on PATH) doesn't silently use cgo. $(BIN): $(BPF_OBJ) $(GO_SRCS) - go build -o $(BIN) . + CGO_ENABLED=0 go build -o $(BIN) . # Grant eBPF caps so the binary runs unprivileged; needs sudo, re-applied per build. setcap: $(BIN) diff --git a/README.md b/README.md index c93b2a8..0e2587b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,10 @@ the actual tool and its target. - Linux with eBPF + BTF (`/sys/kernel/btf/vmlinux`) — standard on modern kernels. - Privilege to load eBPF: run as root, or grant the binary - `cap_bpf,cap_perfmon,cap_sys_admin` (the package does this for you). + `cap_bpf,cap_perfmon,cap_sys_admin` (the package does this for you) to load and + attach the probes. The real client behind an agent-mediated touch (gpg via + gpg-agent, FIDO via ssh-agent) is resolved entirely in-kernel by the eBPF + request graph, so no `/proc`-scanning privileges are needed. - `clang` to build the BPF object. - A notification daemon (`dunst`, `mako`, `swaync`, …). diff --git a/internal/agentpeer/agentpeer.go b/internal/agentpeer/agentpeer.go deleted file mode 100644 index 321b346..0000000 --- a/internal/agentpeer/agentpeer.go +++ /dev/null @@ -1,214 +0,0 @@ -// Package agentpeer finds the client behind a request routed through a local -// agent socket (gpg-agent, ssh-agent). The tracer attributes such requests to -// the agent's helper (scdaemon, ssh-sk-helper); the real client is a socket -// peer of the agent, found via UNIX_DIAG netlink. -package agentpeer - -import ( - "bytes" - "encoding/binary" - "fmt" - "os" - "slices" - "strings" - "syscall" - - "golang.org/x/sys/unix" -) - -const ( - sockDiagByFamily = 20 // SOCK_DIAG_BY_FAMILY - unixDiagPeer = 2 // UNIX_DIAG_PEER - udiagShowPeer = 0x4 // UDIAG_SHOW_PEER -) - -// Resolver finds the client connected to a given agent's socket. -type Resolver struct { - AgentComm string - skip map[string]bool -} - -// New builds a Resolver, ignoring the given internal-daemon comms as peers. -func New(agentComm string, skipComms ...string) *Resolver { - skip := make(map[string]bool, len(skipComms)) - for _, c := range skipComms { - skip[c] = true - } - return &Resolver{AgentComm: agentComm, skip: skip} -} - -var ( - GPGAgent = New("gpg-agent", "scdaemon", "keyboxd") - SSHAgent = New("ssh-agent", "ssh-sk-helper") -) - -// FindClientPID returns the agent's connected client PID, or 0. -func (r *Resolver) FindClientPID() uint32 { - selfPID := uint32(os.Getpid()) - agentPID := findCommPID(r.AgentComm) - if agentPID == 0 { - return 0 - } - for _, ino := range socketInodesForPID(agentPID) { - peerIno, err := socketPeerInode(ino) - if err != nil || peerIno == 0 { - continue - } - pid := pidForSocketInode(peerIno, agentPID) - if pid == 0 || pid == selfPID || r.skip[commForPID(pid)] { - continue - } - return pid - } - return 0 -} - -func commForPID(pid uint32) string { - data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -func findCommPID(name string) uint32 { - entries, err := os.ReadDir("/proc") - if err != nil { - return 0 - } - for _, e := range entries { - var pid uint32 - if _, err := fmt.Sscanf(e.Name(), "%d", &pid); err != nil || pid == 0 { - continue - } - if commForPID(pid) == name { - return pid - } - } - return 0 -} - -func socketInodesForPID(pid uint32) []uint32 { - fds, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid)) - if err != nil { - return nil - } - var inodes []uint32 - for _, fd := range fds { - link, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/%s", pid, fd.Name())) - if err != nil { - continue - } - var ino uint32 - if _, err := fmt.Sscanf(link, "socket:[%d]", &ino); err == nil && ino != 0 { - inodes = append(inodes, ino) - } - } - return inodes -} - -// socketPeerInode returns the peer socket inode for ino via UNIX_DIAG netlink. -func socketPeerInode(ino uint32) (uint32, error) { - fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_SOCK_DIAG) - if err != nil { - return 0, err - } - defer unix.Close(fd) - - body := unixDiagReq{ - SdiagFamily: unix.AF_UNIX, - UdiagStates: ^uint32(0), - UdiagIno: ino, - UdiagShow: udiagShowPeer, - UdiagCookie: [2]uint32{^uint32(0), ^uint32(0)}, - } - var bodyBuf bytes.Buffer - if err := binary.Write(&bodyBuf, binary.NativeEndian, body); err != nil { - return 0, err - } - hdr := unix.NlMsghdr{ - Type: sockDiagByFamily, - Flags: unix.NLM_F_REQUEST, - Len: uint32(unix.SizeofNlMsghdr) + uint32(bodyBuf.Len()), - } - var msg bytes.Buffer - if err := binary.Write(&msg, binary.NativeEndian, hdr); err != nil { - return 0, err - } - msg.Write(bodyBuf.Bytes()) - - if err := unix.Sendto(fd, msg.Bytes(), 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil { - return 0, err - } - resp := make([]byte, 4096) - n, _, err := unix.Recvfrom(fd, resp, 0) - if err != nil { - return 0, err - } - msgs, err := syscall.ParseNetlinkMessage(resp[:n]) - if err != nil { - return 0, err - } - - diagHdrSize := binary.Size(unixDiagMsgHeader{}) - for _, nlmsg := range msgs { - if nlmsg.Header.Type != sockDiagByFamily || len(nlmsg.Data) < diagHdrSize { - continue - } - data := nlmsg.Data[diagHdrSize:] - for len(data) >= 4 { - rtaLen := binary.NativeEndian.Uint16(data[0:2]) - rtaType := binary.NativeEndian.Uint16(data[2:4]) - if rtaLen < 4 || int(rtaLen) > len(data) { - break - } - if rtaType == unixDiagPeer && rtaLen >= 8 { - return binary.NativeEndian.Uint32(data[4:8]), nil - } - aligned := (uint(rtaLen) + 3) &^ 3 - if int(aligned) >= len(data) { - break - } - data = data[aligned:] - } - } - return 0, nil -} - -func pidForSocketInode(targetIno, excludePID uint32) uint32 { - entries, err := os.ReadDir("/proc") - if err != nil { - return 0 - } - for _, e := range entries { - var pid uint32 - if _, err := fmt.Sscanf(e.Name(), "%d", &pid); err != nil || pid == excludePID || pid == 0 { - continue - } - if slices.Contains(socketInodesForPID(pid), targetIno) { - return pid - } - } - return 0 -} - -// unixDiagReq mirrors struct unix_diag_req. -type unixDiagReq struct { - SdiagFamily uint8 - SdiagProtocol uint8 - Pad uint16 - UdiagStates uint32 - UdiagIno uint32 - UdiagShow uint32 - UdiagCookie [2]uint32 -} - -// unixDiagMsgHeader mirrors struct unix_diag_msg (16 bytes). -type unixDiagMsgHeader struct { - UdiagFamily uint8 - UdiagType uint8 - UdiagState uint8 - Pad uint8 - UdiagIno uint32 - UdiagCookie [2]uint32 -} diff --git a/internal/classifier/classifier.go b/internal/classifier/classifier.go index 328916a..9b1430a 100644 --- a/internal/classifier/classifier.go +++ b/internal/classifier/classifier.go @@ -14,19 +14,80 @@ type Process struct { Args []string // argv from /proc/PID/cmdline; may be empty for kernel threads } -// Name returns the most precise process name: basename of argv[0] when -// present, kernel comm otherwise. +// shells are interpreters we look through: a process running `bash /usr/bin/pass` +// is, for classification, "pass" — the script it runs, not the interpreter. +var shells = map[string]bool{ + "sh": true, "bash": true, "dash": true, "zsh": true, + "ksh": true, "ash": true, "fish": true, +} + +// Name returns the most precise process name: the basename of argv[0] when +// present, the kernel comm otherwise. Args are first run through +// NormalizeShellArgs, so a tool shipped as a shell script — e.g. pass, seen as +// `bash /usr/bin/pass …` — is named after the tool, not the interpreter. func (p Process) Name() string { - if len(p.Args) > 0 { - // argv[0] may be a rewritten process title holding the whole command - // line (e.g. Chromium has no NUL separators), so take its first field. - if fields := strings.Fields(p.Args[0]); len(fields) > 0 { - return filepath.Base(fields[0]) - } + if base := argv0Base(NormalizeShellArgs(p.Args)); base != "" { + return base } return p.Comm } +// NormalizeShellArgs rewrites a shell-script invocation to read like a direct +// one: `bash -e /usr/bin/pass show x` becomes `[pass show x]`. This makes a tool +// shipped as a shell script classify by name AND parse its own arguments (rather +// than the interpreter's). Non-shell invocations are returned unchanged. +// proctree applies this when building the tree, so every rule sees clean argv. +func NormalizeShellArgs(args []string) []string { + base := argv0Base(args) + if base == "" || !shells[base] { + return args + } + // The first non-flag argument is the script the shell runs (or the first + // word of a -c command); rewrite it as argv[0] with its arguments after it. + for i := 1; i < len(args); i++ { + a := args[i] + if a == "" || strings.HasPrefix(a, "-") { + continue + } + out := make([]string, 0, len(args)-i) + out = append(out, denixWrapper(filepath.Base(firstField(a)))) + out = append(out, args[i+1:]...) + return out + } + return args +} + +// argv0Base is the de-wrapped basename of the first field of argv[0], or "". +// argv[0] may be a rewritten process title holding the whole command line (e.g. +// Chromium has no NUL separators), so take its first field. +func argv0Base(args []string) string { + if len(args) == 0 { + return "" + } + ff := firstField(args[0]) + if ff == "" { + return "" + } + return denixWrapper(filepath.Base(ff)) +} + +func firstField(s string) string { + if f := strings.Fields(s); len(f) > 0 { + return f[0] + } + return "" +} + +// denixWrapper unwraps Nix's wrapper naming: wrapProgram moves a program `foo` +// to `.foo-wrapped` and ships a `foo` wrapper; the running process often shows +// as `.foo-wrapped`, so map it back to `foo`. +func denixWrapper(name string) string { + if strings.HasPrefix(name, ".") && strings.HasSuffix(name, "-wrapped") { + return name[1 : len(name)-len("-wrapped")] + } + return name +} + // Classification is the structured output produced by a Rule. type Classification struct { Tool string // e.g. "ssh", "gpg", "git", "sops", "firefox" diff --git a/internal/classifier/classifier_test.go b/internal/classifier/classifier_test.go new file mode 100644 index 0000000..975235e --- /dev/null +++ b/internal/classifier/classifier_test.go @@ -0,0 +1,55 @@ +package classifier + +import ( + "reflect" + "testing" +) + +// TestProcessName covers Name's resolution, especially looking through a shell +// to the script it runs so tools shipped as shell scripts (e.g. pass) are named +// after the tool, not "bash". +func TestProcessName(t *testing.T) { + cases := []struct { + name string + p Process + want string + }{ + {"binary argv0", Process{Comm: "gpg", Args: []string{"/usr/bin/gpg", "--sign"}}, "gpg"}, + {"comm fallback when no args", Process{Comm: "scdaemon"}, "scdaemon"}, + {"rewritten title takes first field", Process{Comm: "chromium", Args: []string{"/opt/chrome/chrome --type=gpu"}}, "chrome"}, + {"shell script is named after the script", Process{Comm: "bash", Args: []string{"bash", "/usr/bin/pass", "show", "x"}}, "pass"}, + {"shell script with interpreter flags", Process{Comm: "bash", Args: []string{"bash", "-e", "/nix/store/abc/bin/pass", "show"}}, "pass"}, + {"nix wrapper is unwrapped", Process{Comm: "bash", Args: []string{"bash", "/nix/store/abc/bin/.pass-wrapped", "show", "x"}}, "pass"}, + {"shell -c command names the command", Process{Comm: "bash", Args: []string{"bash", "-c", "gpg --sign"}}, "gpg"}, + {"bare shell stays the shell", Process{Comm: "bash", Args: []string{"bash"}}, "bash"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.p.Name(); got != tc.want { + t.Errorf("Name() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestNormalizeShellArgs verifies a shell-script invocation is rewritten to read +// like a direct one, so downstream rules parse the tool's own arguments. +func TestNormalizeShellArgs(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + {"direct binary unchanged", []string{"/usr/bin/gpg", "--sign"}, []string{"/usr/bin/gpg", "--sign"}}, + {"shell script rewritten", []string{"bash", "/usr/bin/pass", "show", "x"}, []string{"pass", "show", "x"}}, + {"interpreter flags skipped", []string{"bash", "-e", "/nix/store/abc/bin/.pass-wrapped", "show", "x"}, []string{"pass", "show", "x"}}, + {"empty unchanged", nil, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := NormalizeShellArgs(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Errorf("NormalizeShellArgs(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/proctree/proctree.go b/internal/proctree/proctree.go index 0769cdb..4cd5789 100644 --- a/internal/proctree/proctree.go +++ b/internal/proctree/proctree.go @@ -18,7 +18,10 @@ func Walk(pid uint32) []clsf.Process { for p := pid; p > 1 && !seen[p]; { seen[p] = true comm, args, ppid := info(p) - chain = append(chain, clsf.Process{PID: p, Comm: comm, Args: args}) + // Normalize shell-script invocations (e.g. `bash /usr/bin/pass show`) + // so the tool — not the interpreter — names the process and its args + // parse correctly. + chain = append(chain, clsf.Process{PID: p, Comm: comm, Args: clsf.NormalizeShellArgs(args)}) p = ppid } // Reverse: oldest ancestor first. diff --git a/internal/tracer/tracer.bpf.c b/internal/tracer/tracer.bpf.c index 7ec3f9d..ef68da5 100644 --- a/internal/tracer/tracer.bpf.c +++ b/internal/tracer/tracer.bpf.c @@ -40,6 +40,22 @@ struct usb_dev_state { }; struct pt_regs { unsigned long di; // x86_64 arg1 + unsigned long si; // x86_64 arg2 + unsigned long ax; // x86_64 return value +}; +// For the agent connect/accept graph: an accepted unix socket's sk_peer_pid is +// the connecting client's pid (set by the kernel at connect via init_peercred). +struct upid { + int nr; +}; +struct pid { + struct upid numbers[1]; +}; +struct sock { + struct pid *sk_peer_pid; +}; +struct socket { + struct sock *sk; }; #pragma clang attribute pop @@ -91,3 +107,160 @@ int kprobe_proc_do_submiturb(struct pt_regs *ctx) return 0; return emit(EV_CCID | EV_WRITE); } + +// --- agent request graph ---------------------------------------------------- +// A touch through gpg-agent/ssh-agent is reported (via scdaemon/ssh-sk-helper) +// without the real client in its process tree. The client is a socket peer of the +// agent; the question is *which* peer, since an agent can hold several connections +// at once. The assuan and ssh-agent protocols are synchronous and the single +// physical key serializes touches, so "the client of the current touch" is exactly +// the one whose request the agent most recently read. We capture that in two steps: +// unix_accept (return) — mark every socket the agent accepts from a client in +// client_socks. This tells a client connection apart from +// the agent's own link to scdaemon/keyboxd, which it +// connects to and never accepts. +// unix_stream_recvmsg — when the agent reads a request on a marked socket, +// record its peer in agent_clients[kind]. This fires at +// the moment causally tied to the touch, not at connect +// time — which is what removes the connect-vs-touch race +// the old accept-time capture had. + +#define AGENT_GPG 0 +#define AGENT_SSH 1 + +struct client_info { + __u32 pid; + __u32 _pad; + __u64 ts; // bpf_ktime_get_ns at the request read, for staleness/debugging +}; + +// Client whose request the agent most recently read, per agent kind; read by +// tracer.go (AgentClientPID). +struct { + __uint(type, BPF_MAP_TYPE_ARRAY); + __uint(max_entries, 2); + __type(key, __u32); + __type(value, struct client_info); +} agent_clients SEC(".maps"); + +// client_socks marks the unix sockets an agent accepted from clients (value = +// AGENT_* kind). Populated at unix_accept return, consulted on every +// unix_stream_recvmsg. LRU so a closed connection ages out without a teardown +// hook: a sk address later reused by a different socket is harmless — it is +// re-marked at the next accept if it is a client, and the real request-read just +// before a touch overwrites any stale agent_clients entry. +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 1024); + __type(key, __u64); // struct sock * + __type(value, __u32); // AGENT_* kind +} client_socks SEC(".maps"); + +// Carries the accepted socket from the unix_accept entry to its return (where +// the socket's ->sk is grafted), keyed by pid_tgid. +struct accept_ctx { + __u64 newsock; + __u32 kind; +}; +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 256); + __type(key, __u64); + __type(value, struct accept_ctx); +} accept_scratch SEC(".maps"); + +// comm_is reports whether the NUL-terminated comm equals name. +static __always_inline int comm_is(const char *comm, const char *name) +{ +#pragma unroll + for (int i = 0; i < 16; i++) { + if (comm[i] != name[i]) + return 0; + if (name[i] == 0) + return 1; + } + return 1; +} + +// agent_kind maps the current task's comm to an AGENT_* kind, or -1. +static __always_inline int agent_kind(void) +{ + char comm[16]; + bpf_get_current_comm(&comm, sizeof(comm)); + if (comm_is(comm, "gpg-agent")) + return AGENT_GPG; + if (comm_is(comm, "ssh-agent")) + return AGENT_SSH; + return -1; +} + +// unix_accept(struct socket *sock, struct socket *newsock, int flags, bool kern) +SEC("kprobe/unix_accept") +int kprobe_unix_accept(struct pt_regs *ctx) +{ + int kind = agent_kind(); + if (kind < 0) + return 0; + __u64 id = bpf_get_current_pid_tgid(); + struct accept_ctx ac = {}; + ac.newsock = BPF_CORE_READ(ctx, si); // arg2: newsock (->sk set by return) + ac.kind = (__u32)kind; + bpf_map_update_elem(&accept_scratch, &id, &ac, BPF_ANY); + return 0; +} + +SEC("kretprobe/unix_accept") +int kretprobe_unix_accept(struct pt_regs *ctx) +{ + __u64 id = bpf_get_current_pid_tgid(); + struct accept_ctx *ac = bpf_map_lookup_elem(&accept_scratch, &id); + if (!ac) + return 0; + if ((long)BPF_CORE_READ(ctx, ax) != 0) // accept failed + goto out; + + // ->sk is grafted onto newsock by the time accept returns. Mark it as a client + // connection of this agent kind; the peer pid is read later, when the agent + // actually reads a request on it (kprobe_unix_stream_recvmsg). + struct socket *newsock = (struct socket *)ac->newsock; + struct sock *sk = BPF_CORE_READ(newsock, sk); + if (sk) { + __u64 key = (__u64)sk; + __u32 kind = ac->kind; + bpf_map_update_elem(&client_socks, &key, &kind, BPF_ANY); + } +out: + bpf_map_delete_elem(&accept_scratch, &id); + return 0; +} + +// unix_stream_recvmsg(struct socket *sock, struct msghdr *msg, size_t len, int flags) +// The agent reading a request from one of its client connections. Synchronous +// agent protocols plus a serializing physical key make this read the event +// causally tied to the imminent touch, so this — not accept — is where we pin the +// client. Hot-path note: this fires on every unix-stream recv system-wide; the +// single client_socks lookup rejects all non-agent-client reads in one step. +SEC("kprobe/unix_stream_recvmsg") +int kprobe_unix_stream_recvmsg(struct pt_regs *ctx) +{ + struct socket *sock = (struct socket *)BPF_CORE_READ(ctx, di); + struct sock *sk = BPF_CORE_READ(sock, sk); + if (!sk) + return 0; + __u64 key = (__u64)sk; + __u32 *kind = bpf_map_lookup_elem(&client_socks, &key); + if (!kind) + return 0; // not an agent's client connection — the common case + + struct pid *peer = BPF_CORE_READ(sk, sk_peer_pid); + if (!peer) + return 0; + struct client_info ci = {}; + ci.pid = BPF_CORE_READ(peer, numbers[0].nr); + ci.ts = bpf_ktime_get_ns(); + if (ci.pid) { + __u32 k = *kind; + bpf_map_update_elem(&agent_clients, &k, &ci, BPF_ANY); + } + return 0; +} diff --git a/internal/tracer/tracer.go b/internal/tracer/tracer.go index 0a06c39..da9b7c9 100644 --- a/internal/tracer/tracer.go +++ b/internal/tracer/tracer.go @@ -14,6 +14,7 @@ import ( "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/ringbuf" "github.com/cilium/ebpf/rlimit" + "github.com/rs/zerolog/log" "golang.org/x/sys/unix" ) @@ -75,17 +76,23 @@ var kprobes = map[string]string{ } type Tracer struct { - coll *ebpf.Collection - links []link.Link - reader *ringbuf.Reader - events chan Event + coll *ebpf.Collection + links []link.Link + reader *ringbuf.Reader + events chan Event + agentClients *ebpf.Map // agent kind -> client whose request the agent most recently read } // New loads the embedded BPF object, attaches the kprobes, and starts draining // events. func New() (*Tracer, error) { - // File caps mark us non-dumpable, hiding /proc/self/mem, which cilium reads - // to detect the kernel version while loading kprobes. Restore dumpability. + // File caps mark us non-dumpable, which restricts the /proc/self/* files + // (maps, mem, …) cilium/ebpf reads while loading programs. Restore dumpability. + // This does NOT cover kernel-version detection: cilium reads that from the ELF + // auxv the Go runtime captured at startup, before this runs. A pure-Go binary + // gets auxv from the stack (fine), but a cgo build falls back to + // /proc/self/auxv — already blocked here — and then version detection fails. + // That is why the build pins CGO_ENABLED=0 (see Makefile/flake/goreleaser). _ = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0) // Best-effort: only needed (and only permitted) on kernels < 5.11. @@ -117,6 +124,34 @@ func New() (*Tracer, error) { t.links = append(t.links, kp) } + // Optional agent request graph: attribute agent-mediated touches (gpg-agent, + // ssh-agent) to the real client by recording, per agent kind, the client whose + // request the agent most recently read (see tracer.bpf.c). Each probe is + // non-fatal — if a symbol or program is missing, an agent-mediated touch is + // simply attributed to the agent's helper (scdaemon/ssh-sk-helper) instead. + t.agentClients = coll.Maps["agent_clients"] + attachOpt := func(ret bool, sym, progName string) { + prog := coll.Programs[progName] + if prog == nil { + return + } + var l link.Link + var err error + if ret { + l, err = link.Kretprobe(sym, prog, nil) + } else { + l, err = link.Kprobe(sym, prog, nil) + } + if err != nil { + log.Warn().Err(err).Str("sym", sym).Msg("agent graph: attach failed (agent-mediated touches will show the helper)") + return + } + t.links = append(t.links, l) + } + attachOpt(false, "unix_accept", "kprobe_unix_accept") + attachOpt(true, "unix_accept", "kretprobe_unix_accept") + attachOpt(false, "unix_stream_recvmsg", "kprobe_unix_stream_recvmsg") + rd, err := ringbuf.NewReader(coll.Maps["events"]) if err != nil { t.teardown() @@ -128,6 +163,36 @@ func New() (*Tracer, error) { return t, nil } +// Agent kinds for AgentClientPID, matching AGENT_* in tracer.bpf.c. +const ( + AgentGPG uint32 = 0 + AgentSSH uint32 = 1 +) + +// clientInfo mirrors struct client_info in tracer.bpf.c. +type clientInfo struct { + PID uint32 + _ uint32 + TS uint64 +} + +// AgentClientPID returns the pid of the process whose request the given agent +// (AgentGPG/AgentSSH) most recently read, as recorded by the request graph, or 0 +// if unknown. Because the agent protocols are synchronous and the physical key +// serializes touches, that is the client behind the current touch. It lets the +// caller attribute an agent-mediated touch to the real client without scanning +// /proc. +func (t *Tracer) AgentClientPID(kind uint32) uint32 { + if t == nil || t.agentClients == nil { + return 0 + } + var ci clientInfo + if err := t.agentClients.Lookup(&kind, &ci); err != nil { + return 0 + } + return ci.PID +} + // Events delivers parsed events until Close, after which it is closed. func (t *Tracer) Events() <-chan Event { return t.events } diff --git a/main.go b/main.go index 7362860..5425c6e 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,6 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/Talgarr/Whence-Touche/internal/agentpeer" clsf "github.com/Talgarr/Whence-Touche/internal/classifier" "github.com/Talgarr/Whence-Touche/internal/classifier/rules" "github.com/Talgarr/Whence-Touche/internal/config" @@ -91,7 +90,7 @@ func main() { log.Warn().Msg("tracer event stream closed") return } - handleEvent(allRules, sessions, ev, cfg.NotifyThreshold, cfg.NotifyDelay, ntf) + handleEvent(allRules, sessions, ev, cfg.NotifyThreshold, cfg.NotifyDelay, ntf, tr) case <-ticker.C: now := time.Now() @@ -112,7 +111,7 @@ func main() { // handleEvent records an I/O and notifies once a session shows sustained // activity (see session.ready). The notifier ntf decides how the touch is // surfaced — a desktop notification or a log line (see internal/notifier). -func handleEvent(allRules []clsf.Rule, sessions map[sessionKey]*session, ev tracer.Event, threshold int, delay time.Duration, ntf notifier.Notifier) { +func handleEvent(allRules []clsf.Rule, sessions map[sessionKey]*session, ev tracer.Event, threshold int, delay time.Duration, ntf notifier.Notifier, tr *tracer.Tracer) { key := sessionKey{ev.Source, ev.PID} s := sessions[key] now := time.Now() @@ -128,7 +127,7 @@ func handleEvent(allRules []clsf.Rule, sessions map[sessionKey]*session, ev trac } s.shown = true - body := buildBody(allRules, ev) + body := buildBody(allRules, ev, tr) log.Debug().Str("kind", ev.Source.Kind()).Uint32("pid", ev.PID).Str("body", body).Msg("touch needed") id, err := ntf.TouchNeeded(body) @@ -142,11 +141,13 @@ func handleEvent(allRules []clsf.Rule, sessions map[sessionKey]*session, ev trac // buildBody resolves the calling process and renders the notification text. CCID // is attributed to scdaemon, so the GPG client comes from the gpg-agent socket // peer; ssh-agent-mediated FIDO is resolved the same way. -func buildBody(allRules []clsf.Rule, ev tracer.Event) string { +func buildBody(allRules []clsf.Rule, ev tracer.Event, tr *tracer.Tracer) string { pid := ev.PID if ev.Source == tracer.SourceCCID { - if client := agentpeer.GPGAgent.FindClientPID(); client != 0 { + if client := resolveClient(tr, tracer.AgentGPG); client != 0 { pid = client + } else { + log.Debug().Uint32("scdaemon", ev.PID).Msg("no gpg-agent client resolved; attributing to scdaemon") } } if pid == 0 { @@ -155,19 +156,21 @@ func buildBody(allRules []clsf.Rule, ev tracer.Event) string { tree := proctree.Walk(pid) if len(tree) == 0 { + log.Debug().Str("kind", ev.Source.Kind()).Uint32("pid", pid).Msg("process gone before walk") return fmt.Sprintf("%s: pid %d (process gone)", ev.Source.Kind(), pid) } if ev.Source == tracer.SourceHIDRaw && clsf.Has(tree, "ssh-sk-helper") && clsf.Has(tree, "ssh-agent") && !clsf.Has(tree, "ssh") { - if client := agentpeer.SSHAgent.FindClientPID(); client != 0 { + if client := resolveClient(tr, tracer.AgentSSH); client != 0 { if t := proctree.Walk(client); len(t) > 0 { tree = t } } } - log.Debug().Str("tree", proctree.Format(tree)).Msg("process tree") + // The entire process call stack the classifier sees, oldest ancestor first. + log.Debug().Str("stack", proctree.Format(tree)).Msg("call stack") if c, ok := clsf.Classify(allRules, tree); ok { body := c.Tool @@ -182,3 +185,21 @@ func buildBody(allRules []clsf.Rule, ev tracer.Event) string { return proctree.Format(tree) } + +// resolveClient finds the real client behind an agent-mediated request via the +// eBPF request graph (exact, in-kernel, no extra privilege). Returns 0 when the +// graph has no live entry, in which case the caller attributes the touch to the +// agent's helper (scdaemon/ssh-sk-helper). +func resolveClient(tr *tracer.Tracer, kind uint32) uint32 { + pid := tr.AgentClientPID(kind) + if pid == 0 || !pidAlive(pid) { + return 0 + } + log.Debug().Uint32("client", pid).Uint32("kind", kind).Msg("agent client via eBPF graph") + return pid +} + +func pidAlive(pid uint32) bool { + _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) + return err == nil +} diff --git a/main_test.go b/main_test.go index 52b1e23..58e8099 100644 --- a/main_test.go +++ b/main_test.go @@ -91,7 +91,7 @@ func TestHandleEventAccumulates(t *testing.T) { // A short burst of events, all within the same instant for test purposes: // count climbs past the threshold but the span stays ~0, so nothing shows. for i := 0; i < 5; i++ { - handleEvent(nil, sessions, ev, 3, 500*time.Millisecond, notifier.Log{}) + handleEvent(nil, sessions, ev, 3, 500*time.Millisecond, notifier.Log{}, nil) } s := sessions[sessionKey{ev.Source, ev.PID}] diff --git a/packaging/postinstall.sh b/packaging/postinstall.sh index 5dc91b4..8814b41 100755 --- a/packaging/postinstall.sh +++ b/packaging/postinstall.sh @@ -2,6 +2,8 @@ set -e BIN=/usr/bin/whence-touche +# cap_bpf + cap_perfmon + cap_sys_admin load and attach the eBPF probes; the +# in-kernel request graph attributes agent-mediated touches with no extra caps. CAPS=cap_bpf,cap_perfmon,cap_sys_admin+ep if command -v setcap >/dev/null 2>&1; then