Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[*]}"
Expand Down
1 change: 1 addition & 0 deletions internal/classifier/rules/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func All() []classifier.Rule {
Git{},
GPG{},
Browser{},
Auth{},
SSH{},
}
}
97 changes: 97 additions & 0 deletions internal/classifier/rules/auth.go
Original file line number Diff line number Diff line change
@@ -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
}
109 changes: 109 additions & 0 deletions internal/classifier/rules/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}