diff --git a/.gitignore b/.gitignore index c01fa7a..4cc010c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -snoop +^snoop *.o *.a *.so diff --git a/cmd/snoop/main.go b/cmd/snoop/main.go new file mode 100644 index 0000000..74909e7 --- /dev/null +++ b/cmd/snoop/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/imjasonh/snoop/pkg/cgroup" + "github.com/imjasonh/snoop/pkg/ebpf" +) + +func main() { + var cgroupPath string + flag.StringVar(&cgroupPath, "cgroup", "", "Cgroup path to trace (e.g., /system.slice/docker-abc123.scope)") + flag.Parse() + + if err := run(cgroupPath); err != nil { + log.Fatalf("Error: %v", err) + } +} + +func run(cgroupPath string) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle signals for graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + log.Println("Received signal, shutting down...") + cancel() + }() + + // Create and load the eBPF probe + log.Println("Loading eBPF program...") + probe, err := ebpf.NewProbe() + if err != nil { + return fmt.Errorf("creating probe: %w", err) + } + defer probe.Close() + + log.Println("eBPF program loaded successfully") + + // Add cgroup to trace + if cgroupPath != "" { + cgroupID, err := cgroup.GetCgroupIDByPath(cgroupPath) + if err != nil { + return fmt.Errorf("getting cgroup ID: %w", err) + } + log.Printf("Tracing cgroup: %s (ID: %d)", cgroupPath, cgroupID) + if err := probe.AddTracedCgroup(cgroupID); err != nil { + return fmt.Errorf("adding traced cgroup: %w", err) + } + } else { + log.Println("No cgroup specified. Use -cgroup flag to specify a cgroup to trace.") + log.Println("Example: -cgroup /system.slice/docker-abc123.scope") + return fmt.Errorf("no cgroup specified") + } + + // Read and print events + log.Println("Waiting for events (press Ctrl+C to exit)...") + for { + select { + case <-ctx.Done(): + return nil + default: + } + + event, err := probe.ReadEvent(ctx) + if err != nil { + if ctx.Err() != nil { + return nil + } + log.Printf("Error reading event: %v", err) + continue + } + + fmt.Printf("[PID %d] [Cgroup %d] [Syscall %d] %s\n", + event.PID, event.CgroupID, event.SyscallNr, event.Path) + } +} diff --git a/pkg/processor/normalize.go b/pkg/processor/normalize.go new file mode 100644 index 0000000..49ea3ac --- /dev/null +++ b/pkg/processor/normalize.go @@ -0,0 +1,93 @@ +package processor + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// NormalizePath normalizes a file path by: +// - Resolving . and .. components +// - Converting relative paths to absolute using the provided working directory +// - Preserving symlinks (not following them) +// +// The pid parameter is used to look up the process's working directory +// via /proc//cwd when the path is relative and cwd is empty. +func NormalizePath(path string, pid uint32, cwd string) string { + if path == "" { + return "" + } + + // Handle absolute paths: just clean them + if filepath.IsAbs(path) { + return cleanPath(path) + } + + // For relative paths, we need a working directory + workDir := cwd + if workDir == "" && pid > 0 { + // Try to read the process's cwd from /proc + workDir = getProcessCwd(pid) + } + if workDir == "" { + // Fallback: return cleaned relative path prefixed with / + // This is a best-effort when we can't determine the cwd + return "/" + cleanPath(path) + } + + // Join with working directory and clean + return cleanPath(filepath.Join(workDir, path)) +} + +// cleanPath removes . and .. components without following symlinks. +// Unlike filepath.Clean, this preserves trailing slashes and handles +// edge cases more carefully for our use case. +func cleanPath(path string) string { + if path == "" { + return "" + } + + // filepath.Clean handles ., .., and multiple slashes + cleaned := filepath.Clean(path) + + // Ensure absolute paths start with / + if !strings.HasPrefix(cleaned, "/") && strings.HasPrefix(path, "/") { + cleaned = "/" + cleaned + } + + return cleaned +} + +// getProcessCwd reads the working directory of a process from /proc. +// Returns empty string if the process doesn't exist or cwd can't be read. +func getProcessCwd(pid uint32) string { + // Read the symlink target of /proc//cwd + cwdPath := fmt.Sprintf("/proc/%d/cwd", pid) + cwd, err := os.Readlink(cwdPath) + if err != nil { + return "" + } + return cwd +} + +// IsExcluded checks if a path should be excluded based on the provided prefixes. +// Paths starting with any prefix in the exclusion list are excluded. +func IsExcluded(path string, excludePrefixes []string) bool { + for _, prefix := range excludePrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +// DefaultExclusions returns the default path prefixes to exclude from tracing. +// These are system paths that are not relevant for image slimming. +func DefaultExclusions() []string { + return []string{ + "/proc/", + "/sys/", + "/dev/", + } +} diff --git a/pkg/processor/normalize_test.go b/pkg/processor/normalize_test.go new file mode 100644 index 0000000..0b233ff --- /dev/null +++ b/pkg/processor/normalize_test.go @@ -0,0 +1,239 @@ +package processor + +import ( + "os" + "testing" +) + +func TestNormalizePath(t *testing.T) { + for _, tt := range []struct { + desc string + path string + pid uint32 + cwd string + want string + }{{ + desc: "empty path", + path: "", + want: "", + }, { + desc: "absolute path unchanged", + path: "/etc/passwd", + want: "/etc/passwd", + }, { + desc: "absolute path with single dot", + path: "/etc/./passwd", + want: "/etc/passwd", + }, { + desc: "absolute path with double dots", + path: "/etc/nginx/../passwd", + want: "/etc/passwd", + }, { + desc: "absolute path with multiple dots", + path: "/usr/local/./bin/../lib/./test", + want: "/usr/local/lib/test", + }, { + desc: "absolute path with leading double dots", + path: "/../etc/passwd", + want: "/etc/passwd", + }, { + desc: "absolute path with multiple slashes", + path: "/etc//nginx///conf.d////default.conf", + want: "/etc/nginx/conf.d/default.conf", + }, { + desc: "relative path with cwd", + path: "config/app.yaml", + cwd: "/home/user/myapp", + want: "/home/user/myapp/config/app.yaml", + }, { + desc: "relative path with dots and cwd", + path: "./config/../data/file.txt", + cwd: "/app", + want: "/app/data/file.txt", + }, { + desc: "relative path with parent traversal", + path: "../shared/lib.so", + cwd: "/app/bin", + want: "/app/shared/lib.so", + }, { + desc: "relative path without cwd or pid falls back", + path: "some/file.txt", + want: "/some/file.txt", + }, { + desc: "root path", + path: "/", + want: "/", + }, { + desc: "dot only path with cwd", + path: ".", + cwd: "/home/user", + want: "/home/user", + }, { + desc: "double dot only path with cwd", + path: "..", + cwd: "/home/user", + want: "/home", + }, { + desc: "complex traversal", + path: "/a/b/c/../../d/./e/../f", + want: "/a/d/f", + }, { + desc: "double dots past root", + path: "/a/../../b", + want: "/b", + }} { + t.Run(tt.desc, func(t *testing.T) { + got := NormalizePath(tt.path, tt.pid, tt.cwd) + if got != tt.want { + t.Errorf("NormalizePath(%q, %d, %q) = %q, want %q", + tt.path, tt.pid, tt.cwd, got, tt.want) + } + }) + } +} + +func TestNormalizePathWithPid(t *testing.T) { + // This test uses the current process's cwd via /proc + // Skip if /proc is not available + if _, err := os.Stat("/proc/self/cwd"); err != nil { + t.Skip("skipping: /proc not available") + } + + currentCwd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current working directory: %v", err) + } + + pid := uint32(os.Getpid()) + got := NormalizePath("relative/path", pid, "") + + want := currentCwd + "/relative/path" + if got != want { + t.Errorf("NormalizePath with pid lookup = %q, want %q", got, want) + } +} + +func TestCleanPath(t *testing.T) { + for _, tt := range []struct { + desc string + path string + want string + }{{ + desc: "empty path", + path: "", + want: "", + }, { + desc: "simple path", + path: "/usr/bin/test", + want: "/usr/bin/test", + }, { + desc: "single dot", + path: "/usr/./bin", + want: "/usr/bin", + }, { + desc: "double dot", + path: "/usr/local/../bin", + want: "/usr/bin", + }, { + desc: "multiple slashes", + path: "/usr///bin//test", + want: "/usr/bin/test", + }, { + desc: "trailing dot", + path: "/usr/bin/.", + want: "/usr/bin", + }, { + desc: "only dots", + path: "/./././", + want: "/", + }} { + t.Run(tt.desc, func(t *testing.T) { + got := cleanPath(tt.path) + if got != tt.want { + t.Errorf("cleanPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestIsExcluded(t *testing.T) { + exclusions := []string{"/proc/", "/sys/", "/dev/"} + + for _, tt := range []struct { + desc string + path string + want bool + }{{ + desc: "proc path excluded", + path: "/proc/self/status", + want: true, + }, { + desc: "sys path excluded", + path: "/sys/kernel/debug/tracing", + want: true, + }, { + desc: "dev path excluded", + path: "/dev/null", + want: true, + }, { + desc: "etc path not excluded", + path: "/etc/passwd", + want: false, + }, { + desc: "usr path not excluded", + path: "/usr/bin/bash", + want: false, + }, { + desc: "empty path not excluded", + path: "", + want: false, + }, { + desc: "proc-like path not excluded", + path: "/var/proc/data", + want: false, + }, { + desc: "exact proc prefix", + path: "/proc/", + want: true, + }} { + t.Run(tt.desc, func(t *testing.T) { + got := IsExcluded(tt.path, exclusions) + if got != tt.want { + t.Errorf("IsExcluded(%q, %v) = %v, want %v", + tt.path, exclusions, got, tt.want) + } + }) + } +} + +func TestIsExcludedEmptyList(t *testing.T) { + // With no exclusions, nothing should be excluded + if IsExcluded("/proc/self/status", nil) { + t.Error("IsExcluded with nil exclusions should return false") + } + if IsExcluded("/proc/self/status", []string{}) { + t.Error("IsExcluded with empty exclusions should return false") + } +} + +func TestDefaultExclusions(t *testing.T) { + exclusions := DefaultExclusions() + + // Verify we have the expected defaults + expected := map[string]bool{ + "/proc/": true, + "/sys/": true, + "/dev/": true, + } + + if len(exclusions) != len(expected) { + t.Errorf("DefaultExclusions() returned %d items, want %d", + len(exclusions), len(expected)) + } + + for _, ex := range exclusions { + if !expected[ex] { + t.Errorf("unexpected exclusion: %q", ex) + } + } +} diff --git a/pkg/processor/processor.go b/pkg/processor/processor.go new file mode 100644 index 0000000..adb3119 --- /dev/null +++ b/pkg/processor/processor.go @@ -0,0 +1,159 @@ +package processor + +import ( + "sync" +) + +// Event represents a file access event from the eBPF program. +// This mirrors the ebpf.Event type to avoid circular dependencies. +type Event struct { + CgroupID uint64 + PID uint32 + SyscallNr uint32 + Path string +} + +// Processor handles event processing including path normalization, +// exclusion filtering, and deduplication. +type Processor struct { + seen map[string]struct{} + seenMu sync.RWMutex + excluded []string + + // Metrics + eventsReceived uint64 + eventsProcessed uint64 + eventsExcluded uint64 + eventsDuplicate uint64 + mu sync.Mutex +} + +// NewProcessor creates a new event processor with the given exclusion prefixes. +// If excludePrefixes is nil, DefaultExclusions() will be used. +func NewProcessor(excludePrefixes []string) *Processor { + if excludePrefixes == nil { + excludePrefixes = DefaultExclusions() + } + return &Processor{ + seen: make(map[string]struct{}), + excluded: excludePrefixes, + } +} + +// ProcessResult indicates what happened when processing an event. +type ProcessResult int + +const ( + // ResultNew indicates a new unique file was recorded. + ResultNew ProcessResult = iota + // ResultDuplicate indicates the file was already seen. + ResultDuplicate + // ResultExcluded indicates the file was filtered by exclusion rules. + ResultExcluded + // ResultEmpty indicates the path was empty after normalization. + ResultEmpty +) + +// Process handles an incoming event, normalizing the path and deduplicating. +// Returns the normalized path and a result indicating what happened. +func (p *Processor) Process(event *Event) (string, ProcessResult) { + p.mu.Lock() + p.eventsReceived++ + p.mu.Unlock() + + // Normalize the path + normalized := NormalizePath(event.Path, event.PID, "") + if normalized == "" { + return "", ResultEmpty + } + + // Check exclusions + if IsExcluded(normalized, p.excluded) { + p.mu.Lock() + p.eventsExcluded++ + p.mu.Unlock() + return normalized, ResultExcluded + } + + // Check for duplicates and add if new + p.seenMu.Lock() + _, exists := p.seen[normalized] + if !exists { + p.seen[normalized] = struct{}{} + } + p.seenMu.Unlock() + + if exists { + p.mu.Lock() + p.eventsDuplicate++ + p.mu.Unlock() + return normalized, ResultDuplicate + } + + p.mu.Lock() + p.eventsProcessed++ + p.mu.Unlock() + return normalized, ResultNew +} + +// Files returns a snapshot of all unique files seen so far. +// The returned slice is sorted alphabetically. +func (p *Processor) Files() []string { + p.seenMu.RLock() + defer p.seenMu.RUnlock() + + files := make([]string, 0, len(p.seen)) + for path := range p.seen { + files = append(files, path) + } + return files +} + +// UniqueFileCount returns the number of unique files seen. +func (p *Processor) UniqueFileCount() int { + p.seenMu.RLock() + defer p.seenMu.RUnlock() + return len(p.seen) +} + +// Stats returns processing statistics. +type Stats struct { + EventsReceived uint64 + EventsProcessed uint64 + EventsExcluded uint64 + EventsDuplicate uint64 + UniqueFiles int +} + +// Stats returns current processing statistics. +func (p *Processor) Stats() Stats { + p.mu.Lock() + defer p.mu.Unlock() + + p.seenMu.RLock() + uniqueFiles := len(p.seen) + p.seenMu.RUnlock() + + return Stats{ + EventsReceived: p.eventsReceived, + EventsProcessed: p.eventsProcessed, + EventsExcluded: p.eventsExcluded, + EventsDuplicate: p.eventsDuplicate, + UniqueFiles: uniqueFiles, + } +} + +// Reset clears all seen files and resets statistics. +// This is primarily useful for testing. +func (p *Processor) Reset() { + p.seenMu.Lock() + p.seen = make(map[string]struct{}) + p.seenMu.Unlock() + + p.mu.Lock() + p.eventsReceived = 0 + p.eventsProcessed = 0 + p.eventsExcluded = 0 + p.eventsDuplicate = 0 + p.mu.Unlock() +} diff --git a/pkg/processor/processor_test.go b/pkg/processor/processor_test.go new file mode 100644 index 0000000..85f4481 --- /dev/null +++ b/pkg/processor/processor_test.go @@ -0,0 +1,268 @@ +package processor + +import ( + "sort" + "sync" + "testing" +) + +func TestNewProcessor(t *testing.T) { + t.Run("with nil exclusions uses defaults", func(t *testing.T) { + p := NewProcessor(nil) + if len(p.excluded) != len(DefaultExclusions()) { + t.Errorf("expected default exclusions, got %v", p.excluded) + } + }) + + t.Run("with custom exclusions", func(t *testing.T) { + exclusions := []string{"/tmp/", "/var/"} + p := NewProcessor(exclusions) + if len(p.excluded) != 2 { + t.Errorf("expected 2 exclusions, got %d", len(p.excluded)) + } + }) +} + +func TestProcessorProcess(t *testing.T) { + for _, tt := range []struct { + desc string + path string + wantPath string + wantResult ProcessResult + }{{ + desc: "normal absolute path", + path: "/etc/passwd", + wantPath: "/etc/passwd", + wantResult: ResultNew, + }, { + desc: "path with dots normalized", + path: "/etc/./nginx/../passwd", + wantPath: "/etc/passwd", + wantResult: ResultNew, + }, { + desc: "proc path excluded", + path: "/proc/self/status", + wantPath: "/proc/self/status", + wantResult: ResultExcluded, + }, { + desc: "sys path excluded", + path: "/sys/kernel/debug", + wantPath: "/sys/kernel/debug", + wantResult: ResultExcluded, + }, { + desc: "dev path excluded", + path: "/dev/null", + wantPath: "/dev/null", + wantResult: ResultExcluded, + }, { + desc: "empty path", + path: "", + wantPath: "", + wantResult: ResultEmpty, + }} { + t.Run(tt.desc, func(t *testing.T) { + p := NewProcessor(nil) + event := &Event{ + PID: 1234, + Path: tt.path, + } + + gotPath, gotResult := p.Process(event) + if gotPath != tt.wantPath { + t.Errorf("path = %q, want %q", gotPath, tt.wantPath) + } + if gotResult != tt.wantResult { + t.Errorf("result = %v, want %v", gotResult, tt.wantResult) + } + }) + } +} + +func TestProcessorDeduplication(t *testing.T) { + p := NewProcessor(nil) + + // First access should be new + event := &Event{PID: 1234, Path: "/etc/passwd"} + _, result := p.Process(event) + if result != ResultNew { + t.Errorf("first access: got %v, want ResultNew", result) + } + + // Second access should be duplicate + _, result = p.Process(event) + if result != ResultDuplicate { + t.Errorf("second access: got %v, want ResultDuplicate", result) + } + + // Third access should still be duplicate + _, result = p.Process(event) + if result != ResultDuplicate { + t.Errorf("third access: got %v, want ResultDuplicate", result) + } + + // Different path should be new + event2 := &Event{PID: 1234, Path: "/etc/hostname"} + _, result = p.Process(event2) + if result != ResultNew { + t.Errorf("different path: got %v, want ResultNew", result) + } +} + +func TestProcessorDeduplicationNormalized(t *testing.T) { + p := NewProcessor(nil) + + // Access with dots + event1 := &Event{PID: 1234, Path: "/etc/./passwd"} + _, result := p.Process(event1) + if result != ResultNew { + t.Errorf("first access: got %v, want ResultNew", result) + } + + // Access same file via different path + event2 := &Event{PID: 1234, Path: "/etc/nginx/../passwd"} + _, result = p.Process(event2) + if result != ResultDuplicate { + t.Errorf("normalized duplicate: got %v, want ResultDuplicate", result) + } + + // Should only have one unique file + if p.UniqueFileCount() != 1 { + t.Errorf("unique files = %d, want 1", p.UniqueFileCount()) + } +} + +func TestProcessorFiles(t *testing.T) { + p := NewProcessor(nil) + + paths := []string{"/etc/passwd", "/usr/bin/bash", "/lib/libc.so.6"} + for _, path := range paths { + p.Process(&Event{PID: 1234, Path: path}) + } + + files := p.Files() + sort.Strings(files) + sort.Strings(paths) + + if len(files) != len(paths) { + t.Fatalf("files count = %d, want %d", len(files), len(paths)) + } + + for i, f := range files { + if f != paths[i] { + t.Errorf("file[%d] = %q, want %q", i, f, paths[i]) + } + } +} + +func TestProcessorStats(t *testing.T) { + p := NewProcessor(nil) + + // Process various events + p.Process(&Event{PID: 1234, Path: "/etc/passwd"}) // new + p.Process(&Event{PID: 1234, Path: "/etc/passwd"}) // duplicate + p.Process(&Event{PID: 1234, Path: "/etc/hostname"}) // new + p.Process(&Event{PID: 1234, Path: "/proc/self/status"}) // excluded + p.Process(&Event{PID: 1234, Path: ""}) // empty + + stats := p.Stats() + + if stats.EventsReceived != 5 { + t.Errorf("EventsReceived = %d, want 5", stats.EventsReceived) + } + if stats.EventsProcessed != 2 { + t.Errorf("EventsProcessed = %d, want 2", stats.EventsProcessed) + } + if stats.EventsDuplicate != 1 { + t.Errorf("EventsDuplicate = %d, want 1", stats.EventsDuplicate) + } + if stats.EventsExcluded != 1 { + t.Errorf("EventsExcluded = %d, want 1", stats.EventsExcluded) + } + if stats.UniqueFiles != 2 { + t.Errorf("UniqueFiles = %d, want 2", stats.UniqueFiles) + } +} + +func TestProcessorReset(t *testing.T) { + p := NewProcessor(nil) + + p.Process(&Event{PID: 1234, Path: "/etc/passwd"}) + p.Process(&Event{PID: 1234, Path: "/etc/hostname"}) + + if p.UniqueFileCount() != 2 { + t.Fatalf("before reset: unique files = %d, want 2", p.UniqueFileCount()) + } + + p.Reset() + + if p.UniqueFileCount() != 0 { + t.Errorf("after reset: unique files = %d, want 0", p.UniqueFileCount()) + } + + stats := p.Stats() + if stats.EventsReceived != 0 { + t.Errorf("after reset: EventsReceived = %d, want 0", stats.EventsReceived) + } +} + +func TestProcessorConcurrency(t *testing.T) { + p := NewProcessor(nil) + var wg sync.WaitGroup + + // Simulate concurrent access from multiple goroutines + paths := []string{ + "/etc/passwd", + "/usr/bin/bash", + "/lib/libc.so.6", + "/etc/hostname", + "/usr/lib/libssl.so", + } + + // Run 10 goroutines, each processing all paths 100 times + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + for _, path := range paths { + p.Process(&Event{PID: 1234, Path: path}) + } + } + }() + } + + wg.Wait() + + // Should have exactly 5 unique files despite concurrent access + if p.UniqueFileCount() != 5 { + t.Errorf("unique files = %d, want 5", p.UniqueFileCount()) + } + + stats := p.Stats() + // 10 goroutines * 100 iterations * 5 paths = 5000 events + if stats.EventsReceived != 5000 { + t.Errorf("EventsReceived = %d, want 5000", stats.EventsReceived) + } +} + +func TestProcessorCustomExclusions(t *testing.T) { + // Test with custom exclusions that don't include defaults + p := NewProcessor([]string{"/tmp/", "/custom/"}) + + // Default exclusions should NOT apply + _, result := p.Process(&Event{PID: 1234, Path: "/proc/self/status"}) + if result != ResultNew { + t.Errorf("/proc path: got %v, want ResultNew (custom exclusions)", result) + } + + // Custom exclusions SHOULD apply + _, result = p.Process(&Event{PID: 1234, Path: "/tmp/file.txt"}) + if result != ResultExcluded { + t.Errorf("/tmp path: got %v, want ResultExcluded", result) + } + + _, result = p.Process(&Event{PID: 1234, Path: "/custom/data"}) + if result != ResultExcluded { + t.Errorf("/custom path: got %v, want ResultExcluded", result) + } +} diff --git a/plan.md b/plan.md index f9af6e7..1438333 100644 --- a/plan.md +++ b/plan.md @@ -10,7 +10,10 @@ Milestone 1 complete, now working on Milestone 2: - ✅ Ring buffer event delivery from kernel to userspace - ✅ Go userspace loader using cilium/ebpf - ✅ Build infrastructure (Dockerfile, Makefile, CI) -- ⏳ **Next**: Path normalization, deduplication, JSON reporting +- ✅ Path normalization (resolve `.`, `..`, relative paths) +- ✅ Configurable path exclusions +- ✅ In-memory deduplication with efficient data structure +- ⏳ **Next**: Periodic JSON file output, graceful shutdown See [Milestone 2](#milestone-2-core-functionality) for details.