1
0
Fork 0
mirror of https://github.com/imjasonh/snoop synced 2026-07-18 06:35:10 +00:00

Add processor package with path normalization and deduplication

This commit is contained in:
Jason Hall 2026-01-14 10:05:45 -05:00
parent 23d94e3049
commit d8c904c3da
7 changed files with 850 additions and 2 deletions

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
# Binaries
snoop
^snoop
*.o
*.a
*.so

86
cmd/snoop/main.go Normal file
View file

@ -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)
}
}

View file

@ -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/<pid>/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/<pid>/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/",
}
}

View file

@ -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)
}
}
}

159
pkg/processor/processor.go Normal file
View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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.