diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2fb80a1 --- /dev/null +++ b/build.sh @@ -0,0 +1,6 @@ +#!/bin/bash +go build -o execguard +sudo cp execguard /usr/local/bin/ +sudo ./execguard --update /usr/local/bin/execguard +echo -e "Running execguard...Hit CTRL+C to end." +sudo execguard diff --git a/execguard.go b/execguard.go index 96fdf05..1452b51 100644 --- a/execguard.go +++ b/execguard.go @@ -1,8 +1,11 @@ package main import ( + "bytes" + "context" "bufio" "sync" + "encoding/binary" "encoding/base64" "crypto/rand" "crypto/sha256" @@ -27,10 +30,13 @@ import ( "github.com/yang3yen/xxtea-go/xxtea" ) +const sizeofFanotifyEventMetadata = int(unsafe.Sizeof(unix.FanotifyEventMetadata{})) + const ( configFile = "/etc/execguard/config.json" dbFile = "/etc/execguard/allowed.db" logFile = "/var/log/execguard.log" + mailPath = "/usr/bin/mail" ) type Config struct { @@ -50,6 +56,7 @@ var migrateMode bool var newKey bool var config *Config var dbMutex sync.Mutex +var alertCache sync.Map func main() { flag.BoolVar(&initMode, "init", false, "initialize and populate allowed executable database") @@ -361,7 +368,13 @@ func periodicScan(dirs []string, db *sql.DB) { } else if !isAllowed(db, absPath) { log.Printf("Found unauthorized executable: %s", absPath) os.Chmod(absPath, info.Mode()&^0111) - go sendAlert(fmt.Sprintf("Unauthorized executable found and blocked: %s", absPath)) + + if _, seen := alertCache.LoadOrStore(absPath, struct{}{}); !seen { + go sendAlert(fmt.Sprintf("Unauthorized execution attempt blocked: %s", absPath), db) + time.AfterFunc(10*time.Minute, func() { + alertCache.Delete(absPath) + }) + } } } return nil @@ -372,75 +385,126 @@ func periodicScan(dirs []string, db *sql.DB) { } func monitorExecutions(db *sql.DB) error { - fd, err := unix.FanotifyInit(unix.FAN_CLOEXEC|unix.FAN_CLASS_CONTENT, unix.O_RDONLY|unix.O_LARGEFILE) - if err != nil { - return err - } - defer unix.Close(fd) + fd, err := unix.FanotifyInit(unix.FAN_CLOEXEC|unix.FAN_CLASS_CONTENT, unix.O_RDONLY|unix.O_LARGEFILE) + if err != nil { + return fmt.Errorf("fanotify init failed: %w", err) + } + defer unix.Close(fd) + + success := false + for _, dir := range config.ProtectedDirs { + if err := unix.FanotifyMark(fd, unix.FAN_MARK_ADD|unix.FAN_MARK_MOUNT, unix.FAN_OPEN_EXEC_PERM, unix.AT_FDCWD, dir); err != nil { + log.Printf("Failed to mark %s: %v", dir, err) + } else { + success = true + } + } + if !success { + return fmt.Errorf("failed to mark any protected directories") + } - for _, dir := range config.ProtectedDirs { - if err := unix.FanotifyMark(fd, unix.FAN_MARK_ADD|unix.FAN_MARK_MOUNT, - unix.FAN_OPEN_EXEC_PERM, unix.AT_FDCWD, dir); err != nil { - log.Printf("Failed to mark %s: %v", dir, err) - } - } + buf := make([]byte, 4096) - buf := make([]byte, 4096) - for { - n, err := unix.Read(fd, buf) - if err != nil { - return err - } - for offset := 0; offset < n; { - meta := (*unix.FanotifyEventMetadata)(unsafe.Pointer(&buf[offset])) - if meta.Event_len == 0 { - break - } - - resp := unix.FanotifyResponse{Fd: meta.Fd, Response: unix.FAN_ALLOW} - defer unix.Close(int(meta.Fd)) - - if meta.Mask&unix.FAN_OPEN_EXEC_PERM != 0 { - fdpath := fmt.Sprintf("/proc/self/fd/%d", meta.Fd) - path, err := os.Readlink(fdpath) - if err == nil { - absPath, _ := filepath.Abs(path) - absPath, _ = filepath.EvalSymlinks(absPath) - info, statErr := os.Stat(absPath) - if statErr == nil && info.Mode().IsRegular() && (info.Mode().Perm()&0111 != 0) { - if initMode { - addToAllowed(db, absPath) - } else if !isAllowed(db, absPath) { - log.Printf("Blocked execution attempt: %s", absPath) - // To avoid locking up the Whole System...use go function on sendAlert!!! - go sendAlert(fmt.Sprintf("Unauthorized execution attempt blocked: %s", absPath)) - resp.Response = unix.FAN_DENY - } - } - } - } + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in monitorExecutions: %v", r) + } + }() - b := (*[unsafe.Sizeof(resp)]byte)(unsafe.Pointer(&resp))[:] - if _, err := unix.Write(fd, b); err != nil { - log.Printf("Fanotify response write error: %v", err) - } + for { + n, err := unix.Read(fd, buf) + if err != nil { + return fmt.Errorf("fanotify read failed: %w", err) + } - offset += int(meta.Event_len) - } - } + for offset := 0; offset < n; { + if n-offset < sizeofFanotifyEventMetadata { + break + } + + meta := (*unix.FanotifyEventMetadata)(unsafe.Pointer(&buf[offset])) + if meta.Event_len == 0 { + break + } + + resp := unix.FanotifyResponse{Fd: meta.Fd, Response: unix.FAN_ALLOW} + shouldClose := true + + if meta.Mask&unix.FAN_OPEN_EXEC_PERM != 0 { + fdpath := fmt.Sprintf("/proc/self/fd/%d", meta.Fd) + path, err := os.Readlink(fdpath) + if err == nil { + absPath, _ := filepath.Abs(path) + absPath, _ = filepath.EvalSymlinks(absPath) + if info, statErr := os.Stat(absPath); statErr == nil && + info.Mode().IsRegular() && (info.Mode().Perm()&0111 != 0) { + + if initMode { + go func(p string, fd int) { + addToAllowed(db, p) + unix.Close(fd) + }(absPath, int(meta.Fd)) + shouldClose = false + } else { + if !isAllowed(db, absPath) { + log.Printf("Blocked execution attempt: %s", absPath) + + if _, seen := alertCache.LoadOrStore(absPath, struct{}{}); !seen { + go sendAlert(fmt.Sprintf("Unauthorized execution attempt blocked: %s", absPath), db) + time.AfterFunc(10*time.Minute, func() { + alertCache.Delete(absPath) + }) + } + + resp.Response = unix.FAN_DENY + } + } + } + } + } + + var respBuf bytes.Buffer + if err := binary.Write(&respBuf, binary.LittleEndian, resp); err != nil { + log.Printf("Failed to encode fanotify response: %v", err) + } else if _, err := unix.Write(fd, respBuf.Bytes()); err != nil { + log.Printf("Fanotify response write error: %v", err) + } + + if shouldClose { + unix.Close(int(meta.Fd)) + } + + offset += int(meta.Event_len) + } + } } -func sendAlert(message string) { - if config.AlertEmail == "" { - return - } - if _, err := exec.LookPath("mail"); err != nil { - log.Printf("Mail command not found: %v", err) - return - } - cmd := exec.Command("mail", "-s", "ExecGuard Alert", config.AlertEmail) - cmd.Stdin = strings.NewReader(message) - if err := cmd.Run(); err != nil { - log.Printf("Failed to send alert: %v", err) - } +func sendAlert(message string, db *sql.DB) { + if config.AlertEmail == "" { + return + } + if !isAllowed(db, mailPath) { + log.Printf("%s not allowed...blocked email, sorry.", mailPath) + return // Prevent system crash! + } + if _, err := os.Stat(mailPath); err != nil { + log.Printf("Mail command not found: %v", err) + return + } + time.Sleep(time.Duration(300) * time.Millisecond) // Must give time for Block to get over with... + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() // Ensure the context is canceled to avoid leaks + + cmd := exec.CommandContext(ctx, mailPath, "-s", "ExecGuard Alert", config.AlertEmail) + cmd.Env = []string{"PATH=/usr/bin:/bin"} // Set a minimal PATH + cmd.Stdin = strings.NewReader(message) + + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + log.Printf("sendAlert timeout after 15s for message: %q", message) + } + if err != nil { + log.Printf("Failed to send alert: %v, output: %s", err, output) + } } diff --git a/update_bins.sh b/update_bins.sh index 9ccb161..ba96afb 100755 --- a/update_bins.sh +++ b/update_bins.sh @@ -14,7 +14,7 @@ for dir in "${DIRS[@]}"; do # Get just the program name without path: prog_name=$(basename "$program") # Run execguard --update on the program echo "Updating execguard for: $program" - execguard --update "$program" + sudo execguard --update "$program" done else echo "Directory not found: $dir" >&2 @@ -22,8 +22,9 @@ for dir in "${DIRS[@]}"; do done # custom files here: +sudo execguard --update /usr/bin/mail if [ -x /usr/local/maldetect/maldet ]; then - execguard --update /usr/local/maldetect/maldet + sudo execguard --update /usr/local/maldetect/maldet fi sudo execguard --update /usr/lib/update-notifier/package-data-downloader echo "Finished processing all directories"