Now put into modules.

main
Robert 7 months ago
parent fba73cd158
commit d885bbc677
  1. 1
      .gitignore
  2. 11
      README.md
  3. 3
      config.json.example
  4. 56
      core/alert/alert.go
  5. 31
      core/configure/configure.go
  6. 95
      core/hasher/hasher.go
  7. 22
      core/make_key/make_key.go
  8. 136
      core/monitor/monitor.go
  9. 94
      core/scanner/scanner.go
  10. 120
      core/sys_database/sys_database.go
  11. 524
      execguard.go
  12. 2
      execguard.service
  13. 2
      export.sh
  14. 6
      go.mod
  15. 9
      vscan_bins.sh

1
.gitignore vendored

@ -1 +1,2 @@
execguard
migrated_apps.txt

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

@ -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"],

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

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

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

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

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

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

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

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

@ -4,7 +4,7 @@ After=network.target
[Service]
ExecStart=/usr/local/bin/execguard --init
Restart=always
Restart=no
[Install]
WantedBy=multi-user.target

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

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

@ -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
Loading…
Cancel
Save