diff --git a/README.md b/README.md index b1d3a0a..dcda2a5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Environment variables (prefix `WHENCE_`): | `gpg` / `gpg2` | sign, decrypt, encrypt, verify | | `ssh` / `scp` / `sftp` | authenticate | | browsers | WebAuthn / passkey | +| `sudo` / `login` / `pkexec` / screen lockers | authenticate (pam_u2f / FIDO2) | Unrecognised callers show the raw process chain. diff --git a/e2e/README.md b/e2e/README.md index ed33d27..3785896 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -43,6 +43,7 @@ a PASS / FAIL / SKIP matrix and exits non-zero if anything failed. | `ssh` | `ssh-keygen -t ed25519-sk` | FIDO2 PIN set on the key | | `age` | `age -d` via `age-plugin-yubikey` | PIV identity (best-effort) | | browser | opens webauthn.io in your default browser | a passkey/WebAuthn credential | +| `pam` | `sudo -v` (drives pam_u2f) | pam_u2f configured in /etc/pam.d + registered key | The watcher is granted only the eBPF caps (`cap_bpf`, `cap_perfmon`, `cap_sys_admin`). An agent-mediated touch (gpg, pass, sops, …) is attributed to diff --git a/e2e/run.sh b/e2e/run.sh index 242e9ab..b07dfe5 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -309,8 +309,31 @@ test_browser() { show_stack } +test_pam() { + command -v sudo >/dev/null || { record pam SKIP "sudo not installed"; return; } + # pam_u2f is system config (root-owned /etc/pam.d) and may not be set up. + # Only run if it looks configured: some /etc/pam.d file references pam_u2f + # AND a key mapping exists. Otherwise SKIP — we can't configure it here. + local keys="${XDG_CONFIG_HOME:-$HOME/.config}/Yubico/u2f_keys" + if ! grep -rqs pam_u2f /etc/pam.d/ 2>/dev/null || + { [ ! -s "$keys" ] && [ ! -s /etc/u2f_mappings ]; }; then + record pam SKIP "pam_u2f not configured (add pam_u2f to /etc/pam.d with a touch-required key)" + return + fi + ask_run "pam — authenticate via sudo (pam_u2f / FIDO2 touch)" || { record pam SKIP "skipped"; return; } + say "When sudo prompts, touch your YubiKey to authenticate via pam_u2f." + touch_now "enter your FIDO PIN if prompted"; mark + # Invalidate cached creds first so PAM actually runs, then authenticate. + sudo -k 2>/dev/null + if timeout "$TOUCH_TIMEOUT" sudo -v >"$WORK/pam.log" 2>&1; then + finish pam sudo + else + record pam FAIL "sudo authentication failed/timed out (see $WORK/pam.log)" + fi +} + # --- driver ------------------------------------------------------------------- -ALL=(gpg pass gopass sops git ssh age browser) +ALL=(gpg pass gopass sops git ssh age browser pam) if [ "$#" -gt 0 ]; then SELECTED=("$@"); else SELECTED=("${ALL[@]}"); fi say "Testing: ${SELECTED[*]}" diff --git a/internal/classifier/rules/all.go b/internal/classifier/rules/all.go index 74bdf43..bd34e20 100644 --- a/internal/classifier/rules/all.go +++ b/internal/classifier/rules/all.go @@ -15,6 +15,7 @@ func All() []classifier.Rule { Git{}, GPG{}, Browser{}, + Auth{}, SSH{}, } } diff --git a/internal/classifier/rules/auth.go b/internal/classifier/rules/auth.go new file mode 100644 index 0000000..d7040f3 --- /dev/null +++ b/internal/classifier/rules/auth.go @@ -0,0 +1,97 @@ +package rules + +import ( + "strings" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +// Auth matches PAM-based authentication backed by pam_u2f / pam-u2f +// (FIDO2/U2F). pam_u2f runs in-process inside the authenticating program and +// talks to the key over hidraw, so the touch fires in that program's own +// process (sudo, login, a screen locker, etc.) rather than in a helper. +// See https://github.com/Yubico/pam-u2f. +type Auth struct{} + +// authNames lists the privilege/login/lock-screen programs that commonly use +// pam_u2f. The kernel `comm` is truncated to 15 chars (TASK_COMM_LEN), and +// FindFirst matches either Process.Name() (argv[0] basename, untruncated) or +// Comm — so for long names we list both the full name and its 15-char +// truncation (e.g. "gdm-session-worker" / "gdm-session-wor", +// "polkit-agent-helper-1" / "polkit-agent-he") to catch the comm-only case. +// +// Note: "sshd" is intentionally excluded — SSH is handled by the SSH rule. +var authNames = []string{ + "sudo", + "su", + "login", + "pkexec", + "polkitd", + "polkit-agent-he", // polkit-agent-helper-1, truncated to 15 chars + "gdm-session-worker", + "gdm-session-wor", // gdm-session-worker, truncated to 15 chars + "gdm-password", + "sddm-helper", + "lightdm", + "greetd", + "swaylock", + "hyprlock", + "i3lock", + "gtklock", +} + +func (Auth) Match(tree []classifier.Process) (classifier.Classification, bool) { + idx, p, ok := classifier.FindFirst(tree, authNames...) + if !ok { + return classifier.Classification{}, false + } + name := p.Name() + return classifier.Classification{ + Tool: name, + Action: "authenticate", + Resource: authResource(name, p), + Depth: idx, + }, true +} + +// authResource derives a short context describing what is being authenticated. +func authResource(name string, p classifier.Process) string { + switch name { + case "sudo": + // The command being authorised: the non-flag args after argv[0]. + var cmd []string + for _, a := range p.Args[safeStart(p):] { + if strings.HasPrefix(a, "-") { + continue + } + cmd = append(cmd, a) + } + if len(cmd) == 0 { + return "as root" + } + return strings.Join(cmd, " ") + case "su": + if target := classifier.FirstPositional(p); target != "" { + return target + } + return "as root" + case "swaylock", "hyprlock", "i3lock", "gtklock": + return "unlock session" + case "login", "gdm-session-worker", "gdm-session-wor", "gdm-password", + "sddm-helper", "lightdm", "greetd": + return "login" + case "pkexec", "polkitd", "polkit-agent-he": + return "privileged action" + default: + return "system authentication" + } +} + +// safeStart returns the index of the first argument after argv[0], or 0 when +// argv is empty (matched via Comm only). +func safeStart(p classifier.Process) int { + if len(p.Args) > 0 { + return 1 + } + return 0 +} diff --git a/internal/classifier/rules/auth_test.go b/internal/classifier/rules/auth_test.go new file mode 100644 index 0000000..55586e2 --- /dev/null +++ b/internal/classifier/rules/auth_test.go @@ -0,0 +1,109 @@ +package rules + +import ( + "testing" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +func TestAuthMatch(t *testing.T) { + tests := []struct { + name string + tree []classifier.Process + wantTool string + wantRes string + wantDep int + wantOK bool + }{ + { + name: "sudo apt update", + tree: []classifier.Process{ + {PID: 1, Comm: "sudo", Args: []string{"sudo", "apt", "update"}}, + }, + wantTool: "sudo", + wantRes: "apt update", + wantDep: 0, + wantOK: true, + }, + { + name: "su someuser", + tree: []classifier.Process{ + {PID: 1, Comm: "su", Args: []string{"su", "someuser"}}, + }, + wantTool: "su", + wantRes: "someuser", + wantDep: 0, + wantOK: true, + }, + { + name: "swaylock screen locker", + tree: []classifier.Process{ + {PID: 1, Comm: "swaylock", Args: []string{"swaylock"}}, + }, + wantTool: "swaylock", + wantRes: "unlock session", + wantDep: 0, + wantOK: true, + }, + { + name: "gdm-session-worker via comm only", + tree: []classifier.Process{ + {PID: 1, Comm: "gdm-session-wor", Args: nil}, + }, + wantTool: "gdm-session-wor", + wantRes: "login", + wantDep: 0, + wantOK: true, + }, + { + name: "sudo nested in a deeper tree", + tree: []classifier.Process{ + {PID: 1, Comm: "bash", Args: []string{"bash"}}, + {PID: 2, Comm: "sudo", Args: []string{"sudo", "-u", "deploy", "systemctl", "restart", "nginx"}}, + }, + wantTool: "sudo", + wantRes: "deploy systemctl restart nginx", + wantDep: 1, + wantOK: true, + }, + { + name: "no matching names", + tree: []classifier.Process{ + {PID: 1, Comm: "bash", Args: []string{"bash"}}, + {PID: 2, Comm: "vim", Args: []string{"vim", "notes.txt"}}, + }, + wantOK: false, + }, + { + name: "sshd is not stolen", + tree: []classifier.Process{ + {PID: 1, Comm: "sshd", Args: []string{"sshd"}}, + }, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := Auth{}.Match(tt.tree) + if ok != tt.wantOK { + t.Fatalf("Match ok = %v, want %v", ok, tt.wantOK) + } + if !tt.wantOK { + return + } + if got.Tool != tt.wantTool { + t.Errorf("Tool = %q, want %q", got.Tool, tt.wantTool) + } + if got.Action != "authenticate" { + t.Errorf("Action = %q, want %q", got.Action, "authenticate") + } + if got.Resource != tt.wantRes { + t.Errorf("Resource = %q, want %q", got.Resource, tt.wantRes) + } + if got.Depth != tt.wantDep { + t.Errorf("Depth = %d, want %d", got.Depth, tt.wantDep) + } + }) + } +}