diff --git a/Makefile b/Makefile index 64116b2..c59e684 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ TESTS_BIN_DIR = test/bin FILE_OPEN_PROG_BIN = ${TESTS_BIN_DIR}/file_open THREAD_EXEC_PROG_BIN = ${TESTS_BIN_DIR}/thread_exec THREAD_FORK_PROG_BIN = ${TESTS_BIN_DIR}/thread_fork +SIGNAL_FORK_PROG_BIN = ${TESTS_BIN_DIR}/signal_fork BASE_IMAGE = keyval/odiglet-base:v1.10 GOLANGCI_LINT_VERSION := v2.7.2 @@ -44,7 +45,7 @@ generate: docker-generate: docker run --rm -v $(shell pwd):/app $(BASE_IMAGE) /bin/sh -c "cd ../app && make generate" -compile-c-tests: $(FILE_OPEN_PROG_BIN) $(THREAD_EXEC_PROG_BIN) $(THREAD_FORK_PROG_BIN) +compile-c-tests: $(FILE_OPEN_PROG_BIN) $(THREAD_EXEC_PROG_BIN) $(THREAD_FORK_PROG_BIN) $(SIGNAL_FORK_PROG_BIN) $(FILE_OPEN_PROG_BIN): test/c_processes/file_open.c | $(TESTS_BIN_DIR) gcc test/c_processes/file_open.c -o $(FILE_OPEN_PROG_BIN) @@ -55,6 +56,9 @@ $(THREAD_EXEC_PROG_BIN): test/c_processes/thread_exec.c | $(TESTS_BIN_DIR) $(THREAD_FORK_PROG_BIN): test/c_processes/thread_fork.c | $(TESTS_BIN_DIR) gcc test/c_processes/thread_fork.c -o $(THREAD_FORK_PROG_BIN) -lpthread +$(SIGNAL_FORK_PROG_BIN): test/c_processes/signal_fork.c | $(TESTS_BIN_DIR) + gcc test/c_processes/signal_fork.c -o $(SIGNAL_FORK_PROG_BIN) + .PHONY: docker-test-debian docker-test-alpine docker-test: docker run --rm \ diff --git a/detector_test.go b/detector_test.go index 98b82ea..bef8825 100644 --- a/detector_test.go +++ b/detector_test.go @@ -1,6 +1,7 @@ package detector import ( + "bufio" "context" "fmt" "os" @@ -8,6 +9,7 @@ import ( "path/filepath" "slices" "strings" + "syscall" "testing" "time" @@ -513,6 +515,95 @@ func TestDetectorInitialScan(t *testing.T) { } } +func TestSignalFork(t *testing.T) { + currentDir, err := os.Getwd() + require.NoError(t, err) + + signalForkBin := filepath.Join(currentDir, "test/bin/signal_fork") + + events := make(chan ProcessEvent, 100) + + // Start the target process BEFORE the detector. + cmd := exec.Command(signalForkBin) + cmd.Env = append(os.Environ(), "USER_ENV=value") + + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + + var stderr strings.Builder + cmd.Stderr = &stderr + + err = cmd.Start() + require.NoError(t, err) + defer func() { + _ = cmd.Process.Signal(syscall.SIGTERM) + _ = cmd.Wait() + }() + + // Wait for the process to signal readiness. + scanner := bufio.NewScanner(stdout) + require.True(t, scanner.Scan(), "expected 'ready' line from signal_fork") + require.Equal(t, "ready", scanner.Text()) + + // Now start the detector — this triggers the initial scan and should pick up + // the already-running signal_fork process. + opts := []DetectorOption{ + WithMinDuration(0), + WithExePathsToFilter(bashLocation), + WithEnvironments("USER_ENV"), + WithEnvPrefixFilter("USER_E"), + } + + d, err := NewDetector(events, opts...) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + go func() { + err := d.Run(ctx) + require.NoError(t, err) + }() + + // Collect the initial exec event from the initial scan. + var execEvent ProcessEvent + select { + case execEvent = <-events: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for initial exec event") + } + assert.Equal(t, ProcessExecEvent.String(), execEvent.EventType.String(), "first event should be exec") + assert.Equal(t, signalForkBin, execEvent.ExecDetails.ExePath) + t.Logf("got exec event for pid %d\n", execEvent.PID) + + time.Sleep(time.Second) + // Send SIGUSR1 to trigger a fork inside the target process. + err = cmd.Process.Signal(syscall.SIGUSR1) + require.NoError(t, err) + + // Collect fork event + var forkEvent ProcessEvent + select { + case forkEvent = <-events: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting fork event") + } + assert.Equal(t, ProcessForkEvent.String(), forkEvent.EventType.String(), "second event should be fork") + + // Send SIGTERM to trigger exit inside the target process. + err = cmd.Process.Signal(syscall.SIGTERM) + require.NoError(t, err) + + // Collect exit event + var exitEvent ProcessEvent + select { + case exitEvent = <-events: + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for exit event") + } + assert.Equal(t, ProcessExitEvent.String(), exitEvent.EventType.String(), "third event should be exit") +} + func envVarsToSlice(envVars map[string]string) []string { var result []string for k, v := range envVars { diff --git a/internal/probe/ebpf/detector.bpf.c b/internal/probe/ebpf/detector.bpf.c index 597924e..00cd11f 100644 --- a/internal/probe/ebpf/detector.bpf.c +++ b/internal/probe/ebpf/detector.bpf.c @@ -463,35 +463,44 @@ int BPF_PROG(tracepoint_btf__sched__sched_process_fork, struct task_struct *pare } // filter only relevant pids based on the parent - // check if that this clone/fork is called from a process we are tracking - // since fork can be called by a non-leader thread, we need to check the parent tgid in the map - void *found = bpf_map_lookup_elem(&tracked_pids_to_ns_pids, &parent_tgid); + // check if that this clone/fork is called from a process we are tracking. + // since fork can be called by a non-leader thread, we must use the group leader's task + // since the execve probe updates the map based on tgid. + // In addition, user-space can write to user_pid_to_container_pid map in order to tell us to track this process. + // hence we need to look based on that map, otherwise we might miss the event. + struct task_struct *parent_leader = BPF_CORE_READ(parent, group_leader); + pids_in_ns_t parent_pids = {0}; + ret_code = get_pid_for_configured_ns(parent_leader, &parent_pids, parent_tgid); + if (ret_code < 0) { + return 0; + } + void *found = bpf_map_lookup_elem(&user_pid_to_container_pid, &parent_pids.configured_ns_pid); if (found == NULL) { return 0; } - pids_in_ns_t pids = {0}; + pids_in_ns_t child_pids = {0}; - ret_code = get_pid_for_configured_ns(child, &pids, child_tgid); + ret_code = get_pid_for_configured_ns(child, &child_pids, child_tgid); if (ret_code < 0) { return 0; } // track this child pid - ret_code = bpf_map_update_elem(&tracked_pids_to_ns_pids, &child_tgid, &pids.configured_ns_pid, BPF_ANY); + ret_code = bpf_map_update_elem(&tracked_pids_to_ns_pids, &child_tgid, &child_pids.configured_ns_pid, BPF_ANY); if (ret_code != 0) { return 0; } // populate the map with the container pid, so that user space can read it - ret_code = bpf_map_update_elem(&user_pid_to_container_pid, &pids.configured_ns_pid, &pids.last_level_pid, BPF_ANY); + ret_code = bpf_map_update_elem(&user_pid_to_container_pid, &child_pids.configured_ns_pid, &child_pids.last_level_pid, BPF_ANY); if (ret_code != 0) { return 0; } process_event_t event = { .type = PROCESS_FORK, - .pid = pids.configured_ns_pid, + .pid = child_pids.configured_ns_pid, }; bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event)); @@ -507,7 +516,13 @@ int tracepoint__sched__sched_process_fork(struct trace_event_raw_sched_process_f // check if that this clone/fork is called from a process we are tracking (went through execve) void *found = bpf_map_lookup_elem(&tracked_pids_to_ns_pids, &parent_pid); if (found == NULL) { - return 0; + // fallback to try the user_pid map, this can be populated if the user call TrackProcesses + // for an already running process before the execve probe was attached + // this will only work if user space is running in the host pid namespace + found = bpf_map_lookup_elem(&user_pid_to_container_pid, &parent_pid); + if (found == NULL) { + return 0; + } } // we can't make sure here that the child pid is a new process, and not a thread. diff --git a/test/c_processes/signal_fork.c b/test/c_processes/signal_fork.c new file mode 100644 index 0000000..258e04d --- /dev/null +++ b/test/c_processes/signal_fork.c @@ -0,0 +1,53 @@ +/* + * signal_fork - a process that forks in response to SIGUSR1. + * Exits cleanly on SIGTERM. + */ +#include +#include +#include +#include +#include + +static volatile sig_atomic_t got_term = 0; +static volatile sig_atomic_t got_usr1 = 0; + +static void handle_usr1(int sig) { + got_usr1 = 1; +} + +static void handle_term(int sig) { + got_term = 1; +} + +int main(void) { + struct sigaction sa1 = { .sa_handler = handle_usr1 }; + struct sigaction sa_term = { .sa_handler = handle_term }; + sigaction(SIGUSR1, &sa1, NULL); + sigaction(SIGTERM, &sa_term, NULL); + + /* Signal readiness by writing to stdout. */ + printf("ready\n"); + fflush(stdout); + + while (!got_term) { + // pause() causes the calling process (or thread) to sleep until a + // signal is delivered that either terminates the process or causes + // the invocation of a signal-catching function + pause(); + + if (got_usr1) { + got_usr1 = 0; + pid_t pid = fork(); + if (pid == 0) { + /* Child: sleep briefly so the detector can observe it. */ + sleep(1); + _exit(0); + } + /* Parent: reap the child. */ + if (pid > 0) + waitpid(pid, NULL, 0); + } + } + + return 0; +} diff --git a/test/script.sh b/test/script.sh index 6f4fda5..b1fbf88 100755 --- a/test/script.sh +++ b/test/script.sh @@ -6,7 +6,7 @@ echo "Running bash script" for i in {1..10}; do - sleep 0.08 + sleep 0.04 done echo "Bash script completed"