From ed020ba264a4cc8b3217adcd54495648b9d7a7a8 Mon Sep 17 00:00:00 2001 From: onselakin Date: Wed, 1 Oct 2025 13:40:35 +0300 Subject: [PATCH 1/2] feat(watcher): add fanotify-based file access tracking for linux - implemented watcher manager and consumer model - added api route to list consumers - integrated watcher with scan paths for activity observation - introduced build stub for non-linux platforms - added startup signal handling to stop watcher gracefully --- .gitignore | 1 + WATCHER.md | 52 +++++ go.mod | 7 +- go.sum | 2 + handlers/consumers/consumers.go | 25 +++ handlers/scan/scan.go | 13 +- main.go | 37 +++- models/consumer.go | 29 +++ services/scan/scan.go | 9 + store/gorm/store.go | 36 +++- store/memory/store.go | 41 ++++ store/schema/schema.go | 4 + watcher/manager_linux.go | 336 ++++++++++++++++++++++++++++++++ watcher/manager_stub.go | 17 ++ 14 files changed, 601 insertions(+), 8 deletions(-) create mode 100644 WATCHER.md create mode 100644 handlers/consumers/consumers.go create mode 100644 models/consumer.go create mode 100644 watcher/manager_linux.go create mode 100644 watcher/manager_stub.go diff --git a/.gitignore b/.gitignore index 7fffce1..6654780 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ gorm.db cover.out +/.idea/ diff --git a/WATCHER.md b/WATCHER.md new file mode 100644 index 0000000..874390b --- /dev/null +++ b/WATCHER.md @@ -0,0 +1,52 @@ +Watcher integration (Linux only) + +Overview +- vm_server now starts a fanotify-based watcher (when enabled) and records "consumers" — processes that access files under the requested scan paths. +- Consumers are upserted with FilePath, Comm, Exe, RUID, EUID and timestamps (first/last seen). + +Flags +- `-watch-enable` (default: true): enable/disable watcher. +- `-watch-mount` (default: false): watch the whole mount for each provided path (more coverage, noisier). When false, a directory mark is used (non-recursive; child events only). +- `-watch-verbose` (default: false): verbose watcher logs. + +API +- Start a scan: `POST /api/v1/scan` with body `{ "paths": ["/abs/dir"], "regexes": [], "threshold": 1 }` +- List consumers: `GET /api/v1/consumers?filePath=/abs/file/path` + +Ubuntu VM quick start +1) Build vm_server (Linux): + - `cd vm_server` + - `go mod tidy` + - `GOOS=linux GOARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') go build -o eso-vm-server .` +2) Copy to VM and run as root (fanotify needs privileges): + - `sudo ./eso-vm-server -port=1323 -watch-enable=true -watch-verbose=true` +3) Start a scan against a directory: + - `curl -s -X POST http://localhost:1323/api/v1/scan -H 'content-type: application/json' -d '{"paths":["/home/ubuntu/watch"],"regexes":[],"threshold":1}'` +4) Generate activity under the path (new shell): + - `echo hi >> /home/ubuntu/watch/file && cat /home/ubuntu/watch/file` +5) Query consumers: + - `curl -s 'http://localhost:1323/api/v1/consumers?filePath=/home/ubuntu/watch/file' | jq` + +Notes +- Directory marks are not recursive; events fire for direct children. Use `-watch-mount` for broad coverage. +- Running inside containers may require `--privileged`, `--pid=host`, and host bind mounts to see host activity. +- On non-Linux platforms, the watcher is disabled at build time; the server still runs without consumer tracking. + +Permissions and capabilities +- Simplest: run as root (sudo). Fanotify and /proc reads work reliably. +- Non-root options: + - Grant capabilities to the binary or via systemd: + - `CAP_SYS_ADMIN` (required for mount marks; generally needed for fanotify in practice) + - `CAP_DAC_READ_SEARCH` (improves ability to read `/proc/` for comm/exe/uid) + - Example: `sudo setcap 'cap_sys_admin,cap_dac_read_search+ep' /usr/local/bin/eso-vm-server` + - Ensure seccomp/AppArmor do not block fanotify syscalls (use unconfined profile if needed). + - `/proc` may be mounted with `hidepid=2` on hardened systems; this blocks reading other PIDs. Options: + - Run as root, or + - Remount `/proc` with `hidepid=0` (security trade-off), or + - Add `CAP_DAC_READ_SEARCH`. + - Containers/K8s: use `--cap-add SYS_ADMIN`, `--pid=host`, and unconfined seccomp/AppArmor; or `--privileged` for PoC. + +Startup checks +- On startup, the watcher emits warnings if: + - `-watch-mount` is set but CAP_SYS_ADMIN is missing. + - `/proc//status` cannot be read for other processes (likely hidepid/permissions). It suggests fixes. diff --git a/go.mod b/go.mod index a16ea28..b7057fa 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,15 @@ module vm-server go 1.24.2 require ( + github.com/google/uuid v1.6.0 github.com/labstack/echo/v4 v4.13.4 + github.com/s3rj1k/go-fanotify/fanotify v0.0.0-20240229202106-bca3154da60a + golang.org/x/sys v0.33.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.30.0 ) require ( - github.com/google/uuid v1.6.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -19,8 +22,6 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/net v0.40.0 // indirect - golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect golang.org/x/time v0.11.0 // indirect - gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index c5df534..155e8b2 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/s3rj1k/go-fanotify/fanotify v0.0.0-20240229202106-bca3154da60a h1:4VFls9SuqkqeioVevnaeTXrYKQ7JiEsxqKHfxp+/ovA= +github.com/s3rj1k/go-fanotify/fanotify v0.0.0-20240229202106-bca3154da60a/go.mod h1:2zG1g57bc+D6FpNc68gsRXJgkidteqTMhWiiUP3m8UE= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/handlers/consumers/consumers.go b/handlers/consumers/consumers.go new file mode 100644 index 0000000..037d4ee --- /dev/null +++ b/handlers/consumers/consumers.go @@ -0,0 +1,25 @@ +package consumers + +import ( + "net/http" + "vm-server/models" + services "vm-server/services/scan" + + "github.com/labstack/echo/v4" +) + +type Handler struct { + Service *services.Service +} + +func NewHandler(s *services.Service) *Handler { return &Handler{Service: s} } + +// ListConsumersHandler returns consumers, optionally filtered by filePath +func (h *Handler) ListConsumersHandler(c echo.Context) error { + fp := c.QueryParam("filePath") + out, err := h.Service.ListConsumers(&models.ConsumerFilter{FilePath: fp}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to list consumers"}) + } + return c.JSON(http.StatusOK, out) +} diff --git a/handlers/scan/scan.go b/handlers/scan/scan.go index 05d64b3..f57f616 100644 --- a/handlers/scan/scan.go +++ b/handlers/scan/scan.go @@ -6,6 +6,7 @@ import ( "vm-server/jobs" "vm-server/models" services "vm-server/services/scan" + "vm-server/watcher" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -15,11 +16,12 @@ import ( type Handler struct { Service *services.Service Scanner *jobs.Scanner + Watcher *watcher.Manager } // NewHandler creates a new scan handler. -func NewHandler(s *services.Service, sc *jobs.Scanner) *Handler { - return &Handler{Service: s, Scanner: sc} +func NewHandler(s *services.Service, sc *jobs.Scanner, w *watcher.Manager) *Handler { + return &Handler{Service: s, Scanner: sc, Watcher: w} } // ScanHandler handles scan related requests. @@ -41,6 +43,13 @@ func (h *Handler) ScanHandler(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create scan job"}) } + // Activate watcher for requested paths (best-effort) + if h.Watcher != nil { + for _, p := range req.Paths { + _ = h.Watcher.ActivatePath(p) + } + } + // Run the scan in the background // TODO - Make this a channel so we can perform operations on failed scans as well. go h.Scanner.PerformScan(newJob, &req) diff --git a/main.go b/main.go index 13d0e5a..c09b0f7 100644 --- a/main.go +++ b/main.go @@ -6,12 +6,16 @@ import ( "flag" "log" "os" + "os/signal" + "syscall" + consumersHandler "vm-server/handlers/consumers" scanHandler "vm-server/handlers/scan" "vm-server/handlers/secrets" "vm-server/jobs" scanService "vm-server/services/scan" secretsvc "vm-server/services/secrets" memory "vm-server/store/memory" + "vm-server/watcher" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -23,6 +27,9 @@ func main() { certFile := flag.String("cert-file", "", "Path to the server certificate file") keyFile := flag.String("key-file", "", "Path to the server key file") port := flag.String("port", "1323", "Port for the server to listen on") + watchEnable := flag.Bool("watch-enable", true, "Enable fanotify watcher (Linux only)") + watchMount := flag.Bool("watch-mount", false, "Use mount-wide marks for watcher") + watchVerbose := flag.Bool("watch-verbose", false, "Verbose watcher logging") flag.Parse() // Initialize data store @@ -45,13 +52,27 @@ func main() { // Initialize job runner scanner := jobs.NewScanner(scanSvc) + // Initialize fanotify watcher (best-effort) + var watchMgr *watcher.Manager + if *watchEnable { + var werr error + watchMgr, werr = watcher.NewManager(scanSvc, watcher.Options{Mount: *watchMount, Verbose: *watchVerbose}) + if werr != nil { + log.Printf("Watcher disabled: %v", werr) + watchMgr = nil + } + } else { + log.Printf("Watcher disabled by flag") + } + // Initialize handlers - scanHdlr := scanHandler.NewHandler(scanSvc, scanner) + scanHdlr := scanHandler.NewHandler(scanSvc, scanner, watchMgr) err = scanner.Cleanup() if err != nil { log.Fatalf("Failed to cleanup: %v", err) } secretHdlr := secrets.NewHandler(secretsSvc, scanSvc) + consHdlr := consumersHandler.NewHandler(scanSvc) // Echo instance e := echo.New() @@ -70,6 +91,9 @@ func main() { // Secrets routes apiV1.POST("/secrets/:id/version", secretHdlr.CreateSecretVersionHandler) + // Consumers routes + apiV1.GET("/consumers", consHdlr.ListConsumersHandler) + // Configure mTLS if *caFile != "" && *certFile != "" && *keyFile != "" { caCert, err := os.ReadFile(*caFile) @@ -94,4 +118,15 @@ func main() { // Start server without TLS e.Logger.Fatal(e.Start(addr)) } + + // Signal handler to stop watcher + if watchMgr != nil { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Printf("Stopping watcher...") + watchMgr.Stop() + }() + } } diff --git a/models/consumer.go b/models/consumer.go new file mode 100644 index 0000000..ab8a8b6 --- /dev/null +++ b/models/consumer.go @@ -0,0 +1,29 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Consumer represents a process that has accessed a file. +// Uniqueness is defined by the tuple (FilePath, Comm, Exe, RUID, EUID). +type Consumer struct { + gorm.Model + FilePath string `json:"filePath" gorm:"index;uniqueIndex:uniq_consumer,priority:1"` + Comm string `json:"comm" gorm:"uniqueIndex:uniq_consumer,priority:2"` + Exe string `json:"exe" gorm:"uniqueIndex:uniq_consumer,priority:3"` + RUID int `json:"ruid" gorm:"uniqueIndex:uniq_consumer,priority:4"` + EUID int `json:"euid" gorm:"uniqueIndex:uniq_consumer,priority:5"` +} + +// FirstSeen returns CreatedAt for convenience. +func (c *Consumer) FirstSeen() time.Time { return c.CreatedAt } + +// LastSeen returns UpdatedAt for convenience. +func (c *Consumer) LastSeen() time.Time { return c.UpdatedAt } + +// ConsumerFilter filters consumers when listing +type ConsumerFilter struct { + FilePath string +} diff --git a/services/scan/scan.go b/services/scan/scan.go index 4e11ed3..37d77da 100644 --- a/services/scan/scan.go +++ b/services/scan/scan.go @@ -60,3 +60,12 @@ func (s *Service) DeleteScanEntry(scanEntry *models.ScanEntry) error { func (s *Service) ListScanEntries() ([]models.ScanEntry, error) { return s.Store.ListScanEntries() } + +// Consumers +func (s *Service) UpsertConsumer(c *models.Consumer) error { + return s.Store.UpsertConsumer(c) +} + +func (s *Service) ListConsumers(filter *models.ConsumerFilter) ([]models.Consumer, error) { + return s.Store.ListConsumers(filter) +} diff --git a/store/gorm/store.go b/store/gorm/store.go index 3b7af10..80be5ec 100644 --- a/store/gorm/store.go +++ b/store/gorm/store.go @@ -3,6 +3,7 @@ package store import ( "errors" "log" + "time" "vm-server/models" "vm-server/store/schema" @@ -26,8 +27,8 @@ func NewStore() (schema.Store, error) { log.Println("Database connection established.") - // Auto-migrate the schema for ScanJob and ScanEntry models. - err = db.AutoMigrate(&models.ScanJob{}, &models.ScanEntry{}) + // Auto-migrate the schema for ScanJob, ScanEntry and Consumer models. + err = db.AutoMigrate(&models.ScanJob{}, &models.ScanEntry{}, &models.Consumer{}) if err != nil { // Attempt to close the database connection if migration fails. sqlDB, _ := db.DB() @@ -113,3 +114,34 @@ func (s *StoreGorm) ListScanEntries() ([]models.ScanEntry, error) { result := s.DB.Find(&scanEntries) return scanEntries, result.Error } + +// UpsertConsumer creates or updates a consumer based on unique tuple. +func (s *StoreGorm) UpsertConsumer(consumer *models.Consumer) error { + var existing models.Consumer + tx := s.DB.Where("file_path = ? AND comm = ? AND exe = ? AND ruid = ? AND euid = ?", + consumer.FilePath, consumer.Comm, consumer.Exe, consumer.RUID, consumer.EUID). + First(&existing) + if tx.Error != nil { + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return s.DB.Create(consumer).Error + } + return tx.Error + } + existing.UpdatedAt = time.Now() + // Keep latest exe/comm if they changed (rare) + existing.Comm = consumer.Comm + existing.Exe = consumer.Exe + return s.DB.Save(&existing).Error +} + +func (s *StoreGorm) ListConsumers(filter *models.ConsumerFilter) ([]models.Consumer, error) { + var out []models.Consumer + q := s.DB.Model(&models.Consumer{}) + if filter != nil && filter.FilePath != "" { + q = q.Where("file_path = ?", filter.FilePath) + } + if err := q.Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} diff --git a/store/memory/store.go b/store/memory/store.go index 0b1098a..9329ebf 100644 --- a/store/memory/store.go +++ b/store/memory/store.go @@ -1,6 +1,7 @@ package memory import ( + "fmt" "sync" "time" "vm-server/models" @@ -22,6 +23,9 @@ type StoreMemory struct { scanEntries map[uuid.UUID]*models.ScanEntry scanEntriesFingerprint map[string]uuid.UUID mu sync.RWMutex + + // consumers keyed by tuple + consumers map[string]*models.Consumer } // NewStore initializes the database connection and migrates the schema. @@ -32,6 +36,7 @@ func NewStore() (schema.Store, error) { scanEntriesFingerprint: make(map[string]uuid.UUID), scanJobAge: make(map[uuid.UUID]time.Time), scanEntryAge: make(map[uuid.UUID]time.Time), + consumers: make(map[string]*models.Consumer), }, nil } @@ -243,3 +248,39 @@ func (s *StoreMemory) ListScanEntries() ([]models.ScanEntry, error) { } return scanEntries, nil } + +func consumerKey(c *models.Consumer) string { + return c.FilePath + "|" + c.Comm + "|" + c.Exe + "|" + fmt.Sprintf("%d|%d", c.RUID, c.EUID) +} + +// UpsertConsumer stores or updates a consumer entry. +func (s *StoreMemory) UpsertConsumer(consumer *models.Consumer) error { + s.mu.Lock() + defer s.mu.Unlock() + key := consumerKey(consumer) + if existing, ok := s.consumers[key]; ok { + existing.UpdatedAt = time.Now() + existing.Comm = consumer.Comm + existing.Exe = consumer.Exe + return nil + } + // Assign CreatedAt/UpdatedAt + now := time.Now() + consumer.CreatedAt = now + consumer.UpdatedAt = now + s.consumers[key] = consumer + return nil +} + +func (s *StoreMemory) ListConsumers(filter *models.ConsumerFilter) ([]models.Consumer, error) { + s.mu.RLock() + defer s.mu.RUnlock() + var out []models.Consumer + for _, v := range s.consumers { + if filter != nil && filter.FilePath != "" && v.FilePath != filter.FilePath { + continue + } + out = append(out, *v) + } + return out, nil +} diff --git a/store/schema/schema.go b/store/schema/schema.go index 57129d7..aa55128 100644 --- a/store/schema/schema.go +++ b/store/schema/schema.go @@ -16,4 +16,8 @@ type Store interface { UpdateScanEntry(scanEntry *models.ScanEntry) error DeleteScanEntry(scanEntry *models.ScanEntry) error ListScanEntries() ([]models.ScanEntry, error) + + // Consumers + UpsertConsumer(consumer *models.Consumer) error + ListConsumers(filter *models.ConsumerFilter) ([]models.Consumer, error) } diff --git a/watcher/manager_linux.go b/watcher/manager_linux.go new file mode 100644 index 0000000..d700184 --- /dev/null +++ b/watcher/manager_linux.go @@ -0,0 +1,336 @@ +//go:build linux + +package watcher + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/s3rj1k/go-fanotify/fanotify" + "golang.org/x/sys/unix" + "vm-server/models" + scanService "vm-server/services/scan" +) + +type Manager struct { + notify *fanotify.NotifyFD + service *scanService.Service + mu sync.RWMutex + active map[string]int // ref count of activated absolute paths (directories) + stopCh chan struct{} + stopped bool + dedupeMu sync.Mutex + recent map[string]time.Time + recentTTL time.Duration + opts Options +} + +type Options struct { + Mount bool + Verbose bool +} + +func NewManager(svc *scanService.Service, opts Options) (*Manager, error) { + // Proactive environment checks with suggestions + warnEnv(opts) + + n, err := fanotify.Initialize( + unix.FAN_CLOEXEC| + unix.FAN_CLASS_NOTIF| + unix.FAN_UNLIMITED_QUEUE| + unix.FAN_UNLIMITED_MARKS, + os.O_RDONLY| + unix.O_LARGEFILE| + unix.O_CLOEXEC, + ) + if err != nil { + return nil, err + } + m := &Manager{ + notify: n, + service: svc, + active: make(map[string]int), + stopCh: make(chan struct{}), + recent: make(map[string]time.Time), + recentTTL: 2 * time.Second, + opts: opts, + } + go m.loop() + return m, nil +} + +func (m *Manager) isRecent(key string) bool { + m.dedupeMu.Lock() + defer m.dedupeMu.Unlock() + now := time.Now() + for k, t := range m.recent { + if now.Sub(t) > m.recentTTL { + delete(m.recent, k) + } + } + if t, ok := m.recent[key]; ok && now.Sub(t) <= m.recentTTL { + return true + } + m.recent[key] = now + return false +} + +// ActivatePath marks a directory for watching (non-recursive, child events only). +func (m *Manager) ActivatePath(p string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.stopped { + return fmt.Errorf("watcher stopped") + } + abs, err := filepath.Abs(p) + if err != nil { + return err + } + if count := m.active[abs]; count > 0 { + m.active[abs] = count + 1 + return nil + } + // Add mark + mask := uint64(unix.FAN_OPEN | unix.FAN_ACCESS | unix.FAN_CLOSE_WRITE | unix.FAN_OPEN_EXEC) + markFlags := uint(unix.FAN_MARK_ADD) + if m.opts.Mount { + markFlags |= uint(unix.FAN_MARK_MOUNT) + if m.opts.Verbose { + fmt.Printf("[watcher] mount-mark on %s mask=0x%x\n", abs, mask) + } + } else { + mask |= uint64(unix.FAN_EVENT_ON_CHILD) + if m.opts.Verbose { + fmt.Printf("[watcher] dir-mark on %s mask=0x%x\n", abs, mask) + } + } + if err := m.notify.Mark(markFlags, mask, unix.AT_FDCWD, abs); err != nil { + return err + } + m.active[abs] = 1 + return nil +} + +// DeactivatePath decreases refcount and removes mark when zero. +func (m *Manager) DeactivatePath(p string) error { + m.mu.Lock() + defer m.mu.Unlock() + abs, err := filepath.Abs(p) + if err != nil { + return err + } + if count := m.active[abs]; count > 1 { + m.active[abs] = count - 1 + return nil + } + if _, ok := m.active[abs]; ok { + // Remove mark + _ = m.notify.Mark(uint(unix.FAN_MARK_REMOVE), 0, unix.AT_FDCWD, abs) + delete(m.active, abs) + } + return nil +} + +func (m *Manager) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + if m.stopped { + return + } + close(m.stopCh) + m.stopped = true +} + +func (m *Manager) loop() { + for { + select { + case <-m.stopCh: + return + default: + } + data, err := m.notify.GetEvent(os.Getpid()) + if err != nil { + if errors.Is(err, unix.EINTR) { + continue + } + // Sleep a bit on errors to avoid tight loop + time.Sleep(25 * time.Millisecond) + continue + } + if data == nil { + continue + } + func() { + defer data.Close() + p, err := data.GetPath() + if err != nil || p == "" { + return + } + // Filter by active prefixes + m.mu.RLock() + match := false + for base := range m.active { + if strings.HasPrefix(p, base) { + match = true + break + } + } + m.mu.RUnlock() + if !match { + return + } + + // Build event key for dedupe + pid := data.GetPID() + var kinds []string + if data.MatchMask(unix.FAN_OPEN) { + kinds = append(kinds, "OPEN") + } + if data.MatchMask(unix.FAN_ACCESS) { + kinds = append(kinds, "ACCESS") + } + if data.MatchMask(unix.FAN_CLOSE_WRITE) { + kinds = append(kinds, "CLOSE_WRITE") + } + if data.MatchMask(unix.FAN_OPEN_EXEC) { + kinds = append(kinds, "OPEN_EXEC") + } + if len(kinds) == 0 { + kinds = append(kinds, "OTHER") + } + comm := readProcComm(pid) + exe := readProcExe(pid) + ruid, euid, _, _, ok := readProcUIDs(pid) + if !ok { + ruid, euid = -1, -1 + } + dedupeKey := strings.Join(kinds, "|") + "|" + p + "|" + comm + "|" + strconv.Itoa(ruid) + if m.isRecent(dedupeKey) { + return + } + if m.opts.Verbose { + fmt.Printf("[watcher] %s pid=%d uid=%d euid=%d comm=%q path=%q\n", strings.Join(kinds, "|"), pid, ruid, euid, comm, p) + } + // Upsert + _ = m.service.UpsertConsumer(&models.Consumer{ + FilePath: p, + Comm: comm, + Exe: exe, + RUID: ruid, + EUID: euid, + }) + }() + } +} + +func readProcComm(pid int) string { + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func readProcExe(pid int) string { + p := fmt.Sprintf("/proc/%d/exe", pid) + target, err := os.Readlink(p) + if err != nil { + return "" + } + return target +} + +func readProcUIDs(pid int) (int, int, int, int, bool) { + f, err := os.Open(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return 0, 0, 0, 0, false + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "Uid:") { + fields := strings.Fields(line) + if len(fields) >= 5 { + r, _ := strconv.Atoi(fields[1]) + e, _ := strconv.Atoi(fields[2]) + s, _ := strconv.Atoi(fields[3]) + fs, _ := strconv.Atoi(fields[4]) + return r, e, s, fs, true + } + break + } + } + return 0, 0, 0, 0, false +} + +// ---- Environment checks ---- +func warnEnv(opts Options) { + // Only meaningful on linux + if runtime.GOOS != "linux" { + return + } + euid := os.Geteuid() + capEff := readSelfCapEff() + // Check mount marks capability if requested + if opts.Mount && !capEff.has(21) && euid != 0 { // 21 = CAP_SYS_ADMIN + fmt.Printf("[watcher] warning: -watch-mount requested but CAP_SYS_ADMIN is missing (euid=%d)\n", euid) + fmt.Printf("[watcher] suggestion: run with sudo/root, or setcap 'cap_sys_admin+ep' on the binary, or run container with --cap-add SYS_ADMIN or --privileged\n") + } + // Check /proc readability for other processes (comm/exe/uid) + if !canReadProcPID(1) && euid != 0 { + fmt.Printf("[watcher] warning: cannot read /proc//status for other processes (hidepid or permissions)\n") + fmt.Printf("[watcher] suggestion: run as root, or grant 'cap_dac_read_search', or remount /proc with hidepid=0 if acceptable\n") + } +} + +type capMask uint64 + +func readSelfCapEff() capMask { + f, err := os.Open("/proc/self/status") + if err != nil { + return 0 + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "CapEff:") { + // CapEff: 0000000000000000 + parts := strings.Fields(line) + if len(parts) >= 2 { + // hex + if v, err := strconv.ParseUint(parts[1], 16, 64); err == nil { + return capMask(v) + } + } + break + } + } + return 0 +} + +func (m capMask) has(capNum int) bool { + if capNum < 0 || capNum >= 64 { + return false + } + return (uint64(m) & (1 << uint(capNum))) != 0 +} + +func canReadProcPID(pid int) bool { + // Try a cheap read: open status + f, err := os.Open(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return false + } + f.Close() + return true +} diff --git a/watcher/manager_stub.go b/watcher/manager_stub.go new file mode 100644 index 0000000..1c32198 --- /dev/null +++ b/watcher/manager_stub.go @@ -0,0 +1,17 @@ +//go:build !linux + +package watcher + +import ( + "fmt" + scanService "vm-server/services/scan" +) + +type Manager struct{} + +type Options struct{ Mount, Verbose bool } + +func NewManager(_ *scanService.Service, _ Options) (*Manager, error) { return &Manager{}, nil } +func (m *Manager) ActivatePath(_ string) error { return fmt.Errorf("watcher disabled: non-linux") } +func (m *Manager) DeactivatePath(_ string) error { return nil } +func (m *Manager) Stop() {} From 09e4f036f08bb02532b6a44d6ae665a383866861 Mon Sep 17 00:00:00 2001 From: onselakin Date: Thu, 2 Oct 2025 09:29:44 +0300 Subject: [PATCH 2/2] fix: linting errors --- .gitignore | 1 + Makefile | 7 +++++++ jobs/scan.go | 6 ++++-- store/gorm/store.go | 10 ++++++++-- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 6654780..00cb158 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ gorm.db cover.out /.idea/ +/bin/ diff --git a/Makefile b/Makefile index 3b9ab11..c9ed3f9 100644 --- a/Makefile +++ b/Makefile @@ -324,3 +324,10 @@ mv $(1) $(1)-$(3) ;\ } ;\ ln -sf $(1)-$(3) $(1) endef +## Lint Go code with golangci-lint +.PHONY: lint lint-fix +lint: + golangci-lint run --timeout=5m + +lint-fix: + golangci-lint run --fix --timeout=5m diff --git a/jobs/scan.go b/jobs/scan.go index 123836a..51e166e 100644 --- a/jobs/scan.go +++ b/jobs/scan.go @@ -86,7 +86,7 @@ func (s *Scanner) PerformScan(job *models.ScanJob, req *models.ScanRequest) { // Collect file paths go func() { for _, path := range req.Paths { - filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { + if walkErr := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { if err != nil { log.Printf("Failed to walk directory %s: %v", path, err) return nil // Continue walking @@ -95,7 +95,9 @@ func (s *Scanner) PerformScan(job *models.ScanJob, req *models.ScanRequest) { filesToScan <- path } return nil - }) + }); walkErr != nil { + log.Printf("WalkDir returned error for root %s: %v", path, walkErr) + } } close(filesToScan) }() diff --git a/store/gorm/store.go b/store/gorm/store.go index 80be5ec..0f2ee25 100644 --- a/store/gorm/store.go +++ b/store/gorm/store.go @@ -31,8 +31,14 @@ func NewStore() (schema.Store, error) { err = db.AutoMigrate(&models.ScanJob{}, &models.ScanEntry{}, &models.Consumer{}) if err != nil { // Attempt to close the database connection if migration fails. - sqlDB, _ := db.DB() - sqlDB.Close() + sqlDB, dbErr := db.DB() + if dbErr != nil { + // If we can't get the underlying DB, return the migration error. + return nil, err + } + if closeErr := sqlDB.Close(); closeErr != nil { + log.Printf("error closing DB after migration failure: %v", closeErr) + } return nil, err }