diff --git a/.gitignore b/.gitignore index 876543f..23bc29a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ execguard +migrated_apps.txt diff --git a/README.md b/README.md index 704c283..fdb1b13 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,17 @@ Beaware it is possible to lock your self out of your own system with this progra Please look at the go code, etc... ## If LOCKED OUT: -Boot into a Linux Live USB disk. Then mount your hard drive, open the folder to etc, right click and open in new Terminal. From the etc folder... change directory to systemd/system. +Boot into a Linux Live USB disk. Then mount your hard drive, open the folder to etc, right click and open in new Terminal. From the etc folder... change directory to systemd/system. Make sure that --init is turned on...once recovered and loaded all normal programs, so they are added to the allowed system Database, you may remove the --init to go back to enforce mode... ``` cd systemd/system/ -mv execguard.service ../opps.backup +sudo nano execguard.service +ExecStart=/usr/local/bin/execguard --init + +# If, the program still does not work: sudo rm execguard.service reboot ``` ### About execgaurd --init -This will initialize the /etc/execguard/allowed.db SQLite3 Database. +This will initialize the /etc/execguard/system.db SQLite3 Database. It is in Leaning mode... All program will run as normal. ## How it works: @@ -105,7 +108,7 @@ sudo apt remove unattended-upgrades ./sys_update.sh ``` # Migrations -Changes made to passwords, hashes on system with existing data on allowed.db database...need to be migrated. +Changes made to passwords, hashes on system with existing data on system.db database...need to be migrated. ``` sudo service execguard stop sudo ./execguard --migrate diff --git a/config.json.example b/config.json.example index ea34054..1334367 100644 --- a/config.json.example +++ b/config.json.example @@ -1,4 +1,7 @@ { + "db_file": "/etc/execguard/system.db", + "log_file": "/var/log/execguard.log", + "mail_prog": "/usr/bin/mail", "scan_interval": 0, "protected_dirs": ["/home"], "skip_dirs": [".cache",".git"], diff --git a/core/alert/alert.go b/core/alert/alert.go new file mode 100644 index 0000000..a994f1d --- /dev/null +++ b/core/alert/alert.go @@ -0,0 +1,56 @@ +package alert + +import ( + "execguard/core/sys_database" + "execguard/core/configure" + "database/sql" + "log" + "os" + "os/exec" + "time" + "context" + "strings" +) + +var ( + config configure.Config + mailPath string +) + +func SetGlobalConfig(c configure.Config) { + config = c +} +func SetGlobalMail(m string) { + mailPath = m +} + +func SendAlert(message string, db *sql.DB, log log.Logger) { + if config.AlertEmail == "" { + return + } + + if !sys_database.IsAllowed(db, log, 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/core/configure/configure.go b/core/configure/configure.go new file mode 100644 index 0000000..e679c1e --- /dev/null +++ b/core/configure/configure.go @@ -0,0 +1,31 @@ +package configure + +import ( + "os" + "encoding/json" +) + +type Config struct { + DbFile string `json:"db_file"` // optional DB File + LogFile string `json:"log_file"` // optional Log File + MailProg string `json:"mail_prog"` // optional Mail Program + ProtectedDirs []string `json:"protected_dirs"` + AlertEmail string `json:"alert_email"` // optional root@localhost + SkipDirs []string `json:"skip_dirs"` + ScanInterval int `json:"scan_interval"` // in minutes, 0 disables scan + Passphrase string `json:"passphrase"` // optional hash encryption key + HashEncryption string `json:"hash_encryption"` // "none", "xor", or "xxtea" + HashType string `json:"hash_type"` // "sha256" or "sha512" +} + +func LoadConfig(configFile string) (*Config, error) { + data, err := os.ReadFile(configFile) + if err != nil { + return nil, err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/core/hasher/hasher.go b/core/hasher/hasher.go new file mode 100644 index 0000000..23a5f26 --- /dev/null +++ b/core/hasher/hasher.go @@ -0,0 +1,95 @@ +package hasher + +import( + "execguard/core/configure" + "encoding/base64" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "log" + "os" + "strings" + "github.com/yang3yen/xxtea-go/xxtea" +) + +var ( + config configure.Config +) + +func SetGlobalConfig(c configure.Config) { + config = c +} + +func normalizeXXTEAKey(key []byte) []byte { + switch { + case len(key) == 16: + return key + case len(key) > 16: + hash := sha256.Sum256(key) + return hash[:16] + default: // len(key) < 16 + padded := make([]byte, 16) + copy(padded, key) + // Simple padding with repeated key pattern + for i := len(key); i < 16; i++ { + padded[i] = key[i%len(key)] + } + return padded + } +} + +func ComputeHash(path string, log log.Logger) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + + var hashBytes []byte + switch strings.ToLower(config.HashType) { + case "sha256": + sum := sha256.Sum256(data) + hashBytes = sum[:] + case "sha512", "": + sum := sha512.Sum512(data) + hashBytes = sum[:] + default: + log.Printf("Unknown hash_type '%s', defaulting to sha512.", config.HashType) + sum := sha512.Sum512(data) + hashBytes = sum[:] + } + + switch strings.ToLower(config.HashEncryption) { + case "none": + return hex.EncodeToString(hashBytes) + + case "xor": + if config.Passphrase == "" { + log.Println("XOR encryption selected but no passphrase provided.") + return hex.EncodeToString(hashBytes) + } + key := []byte(config.Passphrase) + enc := make([]byte, len(hashBytes)) + for i := 0; i < len(hashBytes); i++ { + enc[i] = hashBytes[i] ^ key[i%len(key)] + } + return hex.EncodeToString(enc) + + case "xxtea": + if config.Passphrase == "" { + log.Println("XXTEA encryption selected but no passphrase provided.") + return hex.EncodeToString(hashBytes) + } + + key := normalizeXXTEAKey([]byte(config.Passphrase)) + enc, err := xxtea.Encrypt(hashBytes, key, false, 0) + if err != nil { + log.Println("XXTEA encryption KEY error???") + return hex.EncodeToString(hashBytes) + } + return base64.StdEncoding.EncodeToString(enc) + + default: + log.Printf("Unknown hash_encryption type: %s. Using plain hash.", config.HashEncryption) + return hex.EncodeToString(hashBytes) + } +} diff --git a/core/make_key/make_key.go b/core/make_key/make_key.go new file mode 100644 index 0000000..4c68ccf --- /dev/null +++ b/core/make_key/make_key.go @@ -0,0 +1,22 @@ +package make_key + +import ( + "io" + "log" + "crypto/rand" + "encoding/base64" + "fmt" +) + +func randReader() io.Reader { + return rand.Reader +} + +func Make_a_key(log log.Logger) { + // XXTEA key should be 16 bytes total...base64 will padd it... + key := make([]byte, 12) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + log.Fatalf("Failed to generate key: %v", err) + } + fmt.Printf("Generated XXTEA key (base64): %s\n", base64.StdEncoding.EncodeToString(key)) +} diff --git a/core/monitor/monitor.go b/core/monitor/monitor.go new file mode 100644 index 0000000..4ef00dc --- /dev/null +++ b/core/monitor/monitor.go @@ -0,0 +1,136 @@ +package monitor + +import ( + "execguard/core/alert" + "execguard/core/configure" + "execguard/core/sys_database" + "database/sql" + "golang.org/x/sys/unix" + "path/filepath" + "encoding/binary" + "sync" + "fmt" + "log" + "os" + "bytes" + "time" + "unsafe" +) + +const sizeofFanotifyEventMetadata = int(unsafe.Sizeof(unix.FanotifyEventMetadata{})) + +var ( + config configure.Config + initMode bool + initFile string + updateFile string + migrateMode bool + dbMutex sync.Mutex + alertCache sync.Map +) + +func SetModes(mode bool, file string, update string, migrate bool) { + initMode = mode + initFile = file + updateFile = update + migrateMode = migrate +} + +func SetGlobalConfig(c configure.Config) { + config = c +} + +func MonitorExecutions(db *sql.DB, log log.Logger, mailPath string) error { + 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") + } + + buf := make([]byte, 4096) + + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in monitorExecutions: %v", r) + } + }() + + for { + n, err := unix.Read(fd, buf) + if err != nil { + return fmt.Errorf("fanotify read failed: %w", err) + } + + 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) { + sys_database.AddToAllowed(db, log, p) + unix.Close(fd) + }(absPath, int(meta.Fd)) + shouldClose = false + } else { + if !sys_database.IsAllowed(db, log, absPath) { + log.Printf("Blocked execution attempt: %s", absPath) + + if _, seen := alertCache.LoadOrStore(absPath, struct{}{}); !seen { + go alert.SendAlert(fmt.Sprintf("Unauthorized execution attempt blocked: %s", absPath), db, log) + 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) + } + } +} diff --git a/core/scanner/scanner.go b/core/scanner/scanner.go new file mode 100644 index 0000000..49aa737 --- /dev/null +++ b/core/scanner/scanner.go @@ -0,0 +1,94 @@ +package scanner + +import ( + "execguard/core/alert" + "execguard/core/configure" + "execguard/core/sys_database" + "database/sql" + "path/filepath" + "time" + "strings" + "fmt" + "log" + "os" + "io/fs" + "sync" +) + + +var ( + config configure.Config + initMode bool + initFile string + updateFile string + migrateMode bool + dbMutex sync.Mutex + alertCache sync.Map +) + +func SetModes(mode bool, file string, update string, migrate bool) { + initMode = mode + initFile = file + updateFile = update + migrateMode = migrate +} + +func SetGlobalConfig(c configure.Config) { + config = c +} + +func PeriodicScan(dirs []string, db *sql.DB, log log.Logger, mailPath string) { + skipSet := make(map[string]struct{}) + for _, skip := range config.SkipDirs { + if abs, err := filepath.Abs(skip); err == nil { + skipSet[abs] = struct{}{} + } + } + interval := time.Duration(config.ScanInterval) * time.Minute + // log.Printf("Starting periodic scan every %v...", interval) + for { + for _, dir := range dirs { + filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + + absPath, err := filepath.Abs(path) + if err != nil { + return nil + } + + // Skip if in any of the SkipDirs + for skipDir := range skipSet { + if strings.HasPrefix(absPath, skipDir) { + return filepath.SkipDir + } + } + + if d.Type().IsRegular() { + info, err := d.Info() + if err != nil || (info.Mode().Perm()&0111 == 0) { + return nil + } + absPath, _ = filepath.EvalSymlinks(absPath) + + if initMode { + sys_database.AddToAllowed(db, log, absPath) + } else if !sys_database.IsAllowed(db, log, absPath) { + log.Printf("Found unauthorized executable: %s", absPath) + os.Chmod(absPath, info.Mode()&^0111) + + if _, seen := alertCache.LoadOrStore(absPath, struct{}{}); !seen { + go alert.SendAlert(fmt.Sprintf("Unauthorized execution attempt blocked: %s", absPath), db, log) + time.AfterFunc(10*time.Minute, func() { + alertCache.Delete(absPath) + }) + } + } + } + return nil + }) + } + time.Sleep(interval) + } +} diff --git a/core/sys_database/sys_database.go b/core/sys_database/sys_database.go new file mode 100644 index 0000000..807fe6f --- /dev/null +++ b/core/sys_database/sys_database.go @@ -0,0 +1,120 @@ +package sys_database + +import ( + "execguard/core/hasher" + "bufio" + "os" + "fmt" + "sync" + "database/sql" + "log" + "strings" + "time" +) + +var ( + initMode bool + initFile string + updateFile string + migrateMode bool + dbMutex sync.Mutex +) + +func SetModes(mode bool, file string, update string, migrate bool) { + initMode = mode + initFile = file + updateFile = update + migrateMode = migrate +} + +func CreateTable(db *sql.DB, log log.Logger) { + query := `CREATE TABLE IF NOT EXISTS allowed ( + path TEXT PRIMARY KEY, + hash TEXT + )` + _, err := db.Exec(query) + if err != nil { + log.Fatalf("Failed to create table: %v", err) + os.Exit(5) // Exit with status code 5 + } +} + +func readFile(db *sql.DB, log log.Logger, input *os.File) { + defer input.Close() + + scanner := bufio.NewScanner(input) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + time.Sleep(time.Duration(100) * time.Millisecond) + AddToAllowed(db, log, line) + log.Printf("Migrated path: %s", line) + } + } + if err := scanner.Err(); err != nil { + log.Printf("Error reading Migrate file: %v", err) + } +} + +func RunInit(db *sql.DB, log log.Logger, path string) { + input, err := os.Open(path) + if err != nil { + log.Fatalf("Failed to open temp file: %v", err) + } + readFile(db, log, input) +} + +func RunMigration(db *sql.DB, log log.Logger) { + tempFile := "Migrate" + + f, err := os.CreateTemp("", tempFile) + if err != nil { + log.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(f.Name()) + + rows, err := db.Query("SELECT path FROM allowed") + if err != nil { + log.Fatalf("Failed to query allowed paths: %v", err) + } + defer rows.Close() + + for rows.Next() { + var path string + if err := rows.Scan(&path); err != nil { + log.Printf("Failed to read row: %v", err) + continue + } + _, _ = fmt.Fprintln(f, path) + } + + // Seek back to start instead of closing/reopening + if _, err := f.Seek(0, 0); err != nil { + log.Fatalf("Failed to seek file: %v", err) + } + readFile(db, log, f) +} + +func IsAllowed(db *sql.DB, log log.Logger, path string) bool { + var storedHash string + hash := hasher.ComputeHash(path, log) + if hash == "" { + return false + } + err := db.QueryRow("SELECT hash FROM allowed WHERE path = ?", path).Scan(&storedHash) + return err == nil && storedHash == hash +} + +func AddToAllowed(db *sql.DB, log log.Logger, path string) { + dbMutex.Lock() + defer dbMutex.Unlock() + + hash := "" + if initMode || updateFile != "" || migrateMode { + hash = hasher.ComputeHash(path, log) + } + _, err := db.Exec("INSERT OR REPLACE INTO allowed(path, hash) VALUES(?, ?)", path, hash) + if err != nil { + log.Printf("Error inserting allowed entry: %v", err) + } +} diff --git a/execguard.go b/execguard.go index 1452b51..024a79e 100644 --- a/execguard.go +++ b/execguard.go @@ -1,85 +1,112 @@ package main import ( - "bytes" - "context" - "bufio" - "sync" - "encoding/binary" - "encoding/base64" - "crypto/rand" - "crypto/sha256" - "crypto/sha512" + "execguard/core/alert" + "execguard/core/configure" + "execguard/core/hasher" + "execguard/core/make_key" + "execguard/core/monitor" + "execguard/core/scanner" + "execguard/core/sys_database" "database/sql" - "encoding/hex" - "encoding/json" "flag" - "fmt" - "io" - "io/fs" "log" "os" - "os/exec" "path/filepath" - "strings" - "time" - "unsafe" - _ "github.com/mattn/go-sqlite3" - "golang.org/x/sys/unix" - "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" + configFileDefault = "/etc/execguard/config.json" + dbFileDefault = "/etc/execguard/system.db" + logFileDefault = "/var/log/execguard.log" + mailPathDefault = "/usr/bin/mail" ) -type Config struct { - ProtectedDirs []string `json:"protected_dirs"` - AlertEmail string `json:"alert_email"` - SkipDirs []string `json:"skip_dirs"` - ScanInterval int `json:"scan_interval"` // in minutes, 0 disables scan - Passphrase string `json:"passphrase"` // optional hash encryption key - HashEncryption string `json:"hash_encryption"` // "none", "xor", or "xxtea" - HashType string `json:"hash_type"` // "sha256" or "sha512" -} - -var initMode bool -var initFile string -var updateFile string -var migrateMode bool -var newKey bool -var config *Config -var dbMutex sync.Mutex -var alertCache sync.Map +var ( + configFile string + dbFile string + logFile string + mailPath string + configFlag string + dbFlag string + logFlag string + mailFlag string + initMode bool + initFile string + updateFile string + migrateMode bool + newKey bool + config *configure.Config +) func main() { + var err error + var log log.Logger + + flag.StringVar(&configFlag, "config", "", "use specified file for config") + flag.StringVar(&dbFlag, "db", "", "use specified file for database") + flag.StringVar(&logFlag, "log", "", "use specified file for Logging") + flag.StringVar(&mailFlag, "mail", "", "use specified file for Mail sending") flag.BoolVar(&initMode, "init", false, "initialize and populate allowed executable database") flag.StringVar(&initFile, "initFile", "", "file containing files to add to allowed database with hash") flag.StringVar(&updateFile, "update", "", "add specified file to allowed database with hash") flag.BoolVar(&migrateMode, "migrate", false, "recompute hashes of all allowed paths using current settings") flag.BoolVar(&newKey, "newKey", false, "generate a new XXTEA-compatible encryption key") flag.Parse() - - if newKey { - // XXTEA key should be 16 bytes total...base64 will padd it... - key := make([]byte, 12) - if _, err := io.ReadFull(rand.Reader, key); err != nil { - log.Fatalf("Failed to generate key: %v", err) - } - fmt.Printf("Generated XXTEA key (base64): %s\n", base64.StdEncoding.EncodeToString(key)) - return - } if os.Geteuid() != 0 { log.Fatal("This program must be run as root") os.Exit(1) // Exit with status code 1 } + + scanner.SetModes(initMode, initFile, updateFile, migrateMode) + monitor.SetModes(initMode, initFile, updateFile, migrateMode) + sys_database.SetModes(initMode, initFile, updateFile, migrateMode) + + if configFlag != "" { + configFile = configFlag + } else { + configFile = configFileDefault + } + + config, err := configure.LoadConfig(configFile) + if err != nil { + log.Fatalf("Error loading config: %v", err) + os.Exit(3) // Exit with status code 3 + } + + hasher.SetGlobalConfig(*config) + alert.SetGlobalConfig(*config) + monitor.SetGlobalConfig(*config) + scanner.SetGlobalConfig(*config) + + // Set Vars...arguemtns first, then config, then defaults + if dbFlag != "" { + dbFile = dbFlag + } else if config.DbFile != "" { + dbFile = config.DbFile + } else { + dbFile = dbFileDefault + } + + if logFlag != "" { + logFile = logFlag + } else if config.LogFile != "" { + logFile = config.LogFile + } else { + logFile = logFileDefault + } + + if mailFlag != "" { + mailPath = mailFlag + } else if config.MailProg != "" { + mailPath = config.MailProg + } else { + mailPath = mailPathDefault + } + + alert.SetGlobalMail(mailPath) logf, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { @@ -87,6 +114,12 @@ func main() { } defer logf.Close() log.SetOutput(logf) + + if newKey { + make_key.Make_a_key(log) + return + } + db, err := sql.Open("sqlite3", dbFile) if err != nil { @@ -95,13 +128,7 @@ func main() { } defer db.Close() - config, err = loadConfig() - if err != nil { - log.Fatalf("Error loading config: %v", err) - os.Exit(3) // Exit with status code 3 - } - - createTable(db) + sys_database.CreateTable(db, log) if initFile != "" { absPath, err := filepath.Abs(initFile) @@ -109,7 +136,7 @@ func main() { log.Fatalf("Invalid init file path: %v", err) os.Exit(1) // Exit with status code 1 } - runInit(db, absPath) + sys_database.RunInit(db, log, absPath) return } @@ -119,13 +146,13 @@ func main() { log.Fatalf("Invalid update file path: %v", err) os.Exit(1) // Exit with status code 1 } - addToAllowed(db, absPath) + sys_database.AddToAllowed(db, log, absPath) log.Printf("Added to allowed list: %s", absPath) return } if migrateMode { - runMigration(db) + sys_database.RunMigration(db, log) return } @@ -136,375 +163,12 @@ func main() { log.Printf("Recovered from scan panic: %v", r) } }() - periodicScan(config.ProtectedDirs, db) + scanner.PeriodicScan(config.ProtectedDirs, db, log, mailPath) }() } - if err := monitorExecutions(db); err != nil { + if err := monitor.MonitorExecutions(db, log, mailPath); err != nil { log.Fatalf("Execution monitoring failed: %v", err) os.Exit(4) // Exit with status code 4 } } - -func randReader() io.Reader { - return rand.Reader -} - -func loadConfig() (*Config, error) { - data, err := os.ReadFile(configFile) - if err != nil { - return nil, err - } - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, err - } - return &cfg, nil -} - -func createTable(db *sql.DB) { - query := `CREATE TABLE IF NOT EXISTS allowed ( - path TEXT PRIMARY KEY, - hash TEXT - )` - _, err := db.Exec(query) - if err != nil { - log.Fatalf("Failed to create table: %v", err) - os.Exit(5) // Exit with status code 5 - } -} - -func readFile(db *sql.DB, input *os.File) { - defer input.Close() - - scanner := bufio.NewScanner(input) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" { - time.Sleep(time.Duration(100) * time.Millisecond) - addToAllowed(db, line) - log.Printf("Migrated path: %s", line) - } - } - if err := scanner.Err(); err != nil { - log.Printf("Error reading Migrate file: %v", err) - } -} - -func runInit(db *sql.DB, path string) { - input, err := os.Open(path) - if err != nil { - log.Fatalf("Failed to open temp file: %v", err) - } - readFile(db, input) -} - -func runMigration(db *sql.DB) { - tempFile := "Migrate" - - f, err := os.CreateTemp("", tempFile) - if err != nil { - log.Fatalf("Failed to create temp file: %v", err) - } - defer os.Remove(f.Name()) - - rows, err := db.Query("SELECT path FROM allowed") - if err != nil { - log.Fatalf("Failed to query allowed paths: %v", err) - } - defer rows.Close() - - for rows.Next() { - var path string - if err := rows.Scan(&path); err != nil { - log.Printf("Failed to read row: %v", err) - continue - } - _, _ = fmt.Fprintln(f, path) - } - - // Seek back to start instead of closing/reopening - if _, err := f.Seek(0, 0); err != nil { - log.Fatalf("Failed to seek file: %v", err) - } - readFile(db, f) -} - -func isAllowed(db *sql.DB, path string) bool { - var storedHash string - hash := computeHash(path) - if hash == "" { - return false - } - err := db.QueryRow("SELECT hash FROM allowed WHERE path = ?", path).Scan(&storedHash) - return err == nil && storedHash == hash -} - -func addToAllowed(db *sql.DB, path string) { - dbMutex.Lock() - defer dbMutex.Unlock() - - hash := "" - if initMode || updateFile != "" || migrateMode { - hash = computeHash(path) - } - _, err := db.Exec("INSERT OR REPLACE INTO allowed(path, hash) VALUES(?, ?)", path, hash) - if err != nil { - log.Printf("Error inserting allowed entry: %v", err) - } -} - -func normalizeXXTEAKey(key []byte) []byte { - switch { - case len(key) == 16: - return key - case len(key) > 16: - hash := sha256.Sum256(key) - return hash[:16] - default: // len(key) < 16 - padded := make([]byte, 16) - copy(padded, key) - // Simple padding with repeated key pattern - for i := len(key); i < 16; i++ { - padded[i] = key[i%len(key)] - } - return padded - } -} - -func computeHash(path string) string { - data, err := os.ReadFile(path) - if err != nil { - return "" - } - - var hashBytes []byte - switch strings.ToLower(config.HashType) { - case "sha256": - sum := sha256.Sum256(data) - hashBytes = sum[:] - case "sha512", "": - sum := sha512.Sum512(data) - hashBytes = sum[:] - default: - log.Printf("Unknown hash_type '%s', defaulting to sha512.", config.HashType) - sum := sha512.Sum512(data) - hashBytes = sum[:] - } - - switch strings.ToLower(config.HashEncryption) { - case "none": - return hex.EncodeToString(hashBytes) - - case "xor": - if config.Passphrase == "" { - log.Println("XOR encryption selected but no passphrase provided.") - return hex.EncodeToString(hashBytes) - } - key := []byte(config.Passphrase) - enc := make([]byte, len(hashBytes)) - for i := 0; i < len(hashBytes); i++ { - enc[i] = hashBytes[i] ^ key[i%len(key)] - } - return hex.EncodeToString(enc) - - case "xxtea": - if config.Passphrase == "" { - log.Println("XXTEA encryption selected but no passphrase provided.") - return hex.EncodeToString(hashBytes) - } - - key := normalizeXXTEAKey([]byte(config.Passphrase)) - enc, err := xxtea.Encrypt(hashBytes, key, false, 0) - if err != nil { - log.Println("XXTEA encryption KEY error???") - return hex.EncodeToString(hashBytes) - } - return base64.StdEncoding.EncodeToString(enc) - - default: - log.Printf("Unknown hash_encryption type: %s. Using plain hash.", config.HashEncryption) - return hex.EncodeToString(hashBytes) - } -} - -func periodicScan(dirs []string, db *sql.DB) { - skipSet := make(map[string]struct{}) - for _, skip := range config.SkipDirs { - if abs, err := filepath.Abs(skip); err == nil { - skipSet[abs] = struct{}{} - } - } - interval := time.Duration(config.ScanInterval) * time.Minute - // log.Printf("Starting periodic scan every %v...", interval) - for { - for _, dir := range dirs { - filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - - absPath, err := filepath.Abs(path) - if err != nil { - return nil - } - - // Skip if in any of the SkipDirs - for skipDir := range skipSet { - if strings.HasPrefix(absPath, skipDir) { - return filepath.SkipDir - } - } - - if d.Type().IsRegular() { - info, err := d.Info() - if err != nil || (info.Mode().Perm()&0111 == 0) { - return nil - } - absPath, _ = filepath.EvalSymlinks(absPath) - - if initMode { - addToAllowed(db, absPath) - } else if !isAllowed(db, absPath) { - log.Printf("Found unauthorized executable: %s", absPath) - os.Chmod(absPath, info.Mode()&^0111) - - 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 - }) - } - time.Sleep(interval) - } -} - -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 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") - } - - buf := make([]byte, 4096) - - defer func() { - if r := recover(); r != nil { - log.Printf("Recovered from panic in monitorExecutions: %v", r) - } - }() - - for { - n, err := unix.Read(fd, buf) - if err != nil { - return fmt.Errorf("fanotify read failed: %w", err) - } - - 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, 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/execguard.service b/execguard.service index 46b1b81..477d3d3 100644 --- a/execguard.service +++ b/execguard.service @@ -4,7 +4,7 @@ After=network.target [Service] ExecStart=/usr/local/bin/execguard --init -Restart=always +Restart=no [Install] WantedBy=multi-user.target diff --git a/export.sh b/export.sh index f3a765e..1fa145d 100644 --- a/export.sh +++ b/export.sh @@ -1,3 +1,3 @@ #!/bin/bash -sudo sqlite3 /etc/execguard/allowed.db "SELECT path FROM allowed;" > migrated_apps.txt +sudo sqlite3 /etc/execguard/system.db "SELECT path FROM allowed;" > migrated_apps.txt echo "On remote PC: \$ sudo execguard --initFile migrated_apps.txt" diff --git a/go.mod b/go.mod index a68cc14..e1cccde 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module execguard go 1.23.6 require ( - github.com/mattn/go-sqlite3 v1.14.28 // indirect - github.com/yang3yen/xxtea-go v1.0.3 // indirect - golang.org/x/sys v0.33.0 // indirect + github.com/mattn/go-sqlite3 v1.14.28 + github.com/yang3yen/xxtea-go v1.0.3 + golang.org/x/sys v0.33.0 ) diff --git a/vscan_bins.sh b/vscan_bins.sh new file mode 100755 index 0000000..e743195 --- /dev/null +++ b/vscan_bins.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# sudo apt purge clamav clamav-daemon clamav-freshclam +sudo service execgaurd stop +if [ ! -x /usr/bin/clamscan ]; then + sudo apt install clamav clamav-daemon clamav-freshclam + sudo freshclam +fi +sudo sqlite3 /etc/execguard/system.db "SELECT path FROM allowed;" > migrated_apps.txt +clamscan -v --file-list=migrated_apps.txt