Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 <asm/*.h> uapi headers under a multiarch
Expand All @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, …).

Expand Down
214 changes: 0 additions & 214 deletions internal/agentpeer/agentpeer.go

This file was deleted.

77 changes: 69 additions & 8 deletions internal/classifier/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions internal/classifier/classifier_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading