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.
272 lines
6.4 KiB
272 lines
6.4 KiB
package main
|
|
|
|
import (
|
|
"crypto/sha512"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unsafe"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
const (
|
|
configFile = "/etc/execguard/config.json"
|
|
dbFile = "/etc/execguard/allowed.db"
|
|
logFile = "/var/log/execguard.log"
|
|
scanInterval = 9 * time.Minute
|
|
)
|
|
|
|
type Config struct {
|
|
ProtectedDirs []string `json:"protected_dirs"`
|
|
AlertEmail string `json:"alert_email"`
|
|
SkipDirs []string `json:"skip_dirs"`
|
|
}
|
|
|
|
var initMode bool
|
|
var updateFile string
|
|
var config *Config
|
|
|
|
func main() {
|
|
flag.BoolVar(&initMode, "init", false, "initialize and populate allowed executable database")
|
|
flag.StringVar(&updateFile, "update", "", "add specified file to allowed database with hash")
|
|
flag.Parse()
|
|
|
|
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()
|
|
|
|
createTable(db)
|
|
|
|
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
|
|
}
|
|
|
|
config, err = loadConfig()
|
|
if err != nil {
|
|
log.Fatalf("Error loading config: %v", err)
|
|
os.Exit(3) // Exit with status code 3
|
|
}
|
|
|
|
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 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 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) {
|
|
hash := ""
|
|
if initMode || updateFile != "" {
|
|
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 computeHash(path string) string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
sha := sha512.Sum512(data)
|
|
return hex.EncodeToString(sha[:])
|
|
}
|
|
|
|
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{}{}
|
|
}
|
|
}
|
|
|
|
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)
|
|
sendAlert(fmt.Sprintf("Unauthorized executable found and blocked: %s", absPath))
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
time.Sleep(scanInterval)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
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)
|
|
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)
|
|
sendAlert(fmt.Sprintf("Unauthorized execution attempt blocked: %s", absPath))
|
|
resp.Response = unix.FAN_DENY
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
b := (*[unsafe.Sizeof(resp)]byte)(unsafe.Pointer(&resp))[:]
|
|
if _, err := unix.Write(fd, b); err != nil {
|
|
log.Printf("Fanotify response write error: %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|