You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
510 lines
14 KiB
510 lines
14 KiB
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"bufio"
|
|
"sync"
|
|
"encoding/binary"
|
|
"encoding/base64"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/sha512"
|
|
"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"
|
|
)
|
|
|
|
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
|
|
|
|
func main() {
|
|
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
|
|
}
|
|
|
|
logf, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
log.Fatalf("Error opening log file: %v", err)
|
|
}
|
|
defer logf.Close()
|
|
log.SetOutput(logf)
|
|
|
|
db, err := sql.Open("sqlite3", dbFile)
|
|
if err != nil {
|
|
log.Fatalf("Error opening database: %v", err)
|
|
os.Exit(2) // Exit with status code 2
|
|
}
|
|
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)
|
|
|
|
if initFile != "" {
|
|
absPath, err := filepath.Abs(initFile)
|
|
if err != nil {
|
|
log.Fatalf("Invalid init file path: %v", err)
|
|
os.Exit(1) // Exit with status code 1
|
|
}
|
|
runInit(db, absPath)
|
|
return
|
|
}
|
|
|
|
if updateFile != "" {
|
|
absPath, err := filepath.Abs(updateFile)
|
|
if err != nil {
|
|
log.Fatalf("Invalid update file path: %v", err)
|
|
os.Exit(1) // Exit with status code 1
|
|
}
|
|
addToAllowed(db, absPath)
|
|
log.Printf("Added to allowed list: %s", absPath)
|
|
return
|
|
}
|
|
|
|
if migrateMode {
|
|
runMigration(db)
|
|
return
|
|
}
|
|
|
|
if config.ScanInterval > 0 {
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Recovered from scan panic: %v", r)
|
|
}
|
|
}()
|
|
periodicScan(config.ProtectedDirs, db)
|
|
}()
|
|
}
|
|
|
|
if err := monitorExecutions(db); 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)
|
|
}
|
|
}
|
|
|