feat(go): advance validation sidecar toward production

This commit is contained in:
Luna 2026-07-22 01:52:19 -07:00
parent 6a06eba255
commit f85c2e831a
No known key found for this signature in database
92 changed files with 8881 additions and 91 deletions

View file

@ -0,0 +1,476 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bufio"
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/perf"
"github.com/cilium/ebpf/rlimit"
"golang.org/x/sys/unix"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
)
const (
syscallMprotect uint32 = iota + 1
syscallMmap
syscallMemfdCreate
syscallPtrace
syscallPrctl
syscallSeccomp
syscallProcessVMReadv
syscallProcessVMWritev
syscallMlock
syscallMlock2
syscallMlockall
hostSetuid
hostSetgid
hostChmod
hostFchmodat
hostChown
hostLchown
hostFchownat
hostCapset
hostFinitModule
hostFileWrite
hostTCPConnect
hostBind
hostListen
hostAccept
hostAccept4
)
var syscallNames = map[uint32]string{
syscallMprotect: "mprotect",
syscallMmap: "mmap",
syscallMemfdCreate: "memfd_create",
syscallPtrace: "ptrace",
syscallPrctl: "prctl",
syscallSeccomp: "seccomp",
syscallProcessVMReadv: "process_vm_readv",
syscallProcessVMWritev: "process_vm_writev",
syscallMlock: "mlock",
syscallMlock2: "mlock2",
syscallMlockall: "mlockall",
}
type syscallEventRaw struct {
PID uint32
PPID uint32
UID uint32
SyscallID uint32
Comm [16]int8
Args [6]uint64
Text [80]int8
}
type SyscallLostSamplesError struct {
Count uint64
}
// SecurityEvent is the typed result of the shared filtered syscall transport.
// Exactly one field is populated.
type SecurityEvent struct {
Syscall *events.SyscallEvent
Host *events.HostEvent
pathDirFD int64
}
func (e SyscallLostSamplesError) Error() string {
return fmt.Sprintf("syscall perf buffer lost %d samples", e.Count)
}
// SyscallSource filters raw syscall entry events in-kernel using a map of
// architecture-specific syscall numbers, then exposes transport-neutral
// SyscallEvent values to the existing rule engine.
type SyscallSource struct {
objects syscallObjects
links []link.Link
reader *perf.Reader
procRoot string
}
func OpenSyscallSource(procRoot string) (*SyscallSource, error) {
if procRoot == "" {
procRoot = "/proc"
}
numbers := monitoredSyscalls()
memlockErr := rlimit.RemoveMemlock()
var objects syscallObjects
if err := loadSyscallObjects(&objects, nil); err != nil {
if memlockErr != nil {
return nil, fmt.Errorf("load syscall BPF objects: %w (memlock adjustment: %v)", err, memlockErr)
}
return nil, fmt.Errorf("load syscall BPF objects: %w", err)
}
for number, identifier := range numbers {
if err := objects.MonitoredSyscalls.Update(number, identifier, ebpf.UpdateAny); err != nil {
objects.Close()
return nil, fmt.Errorf("configure syscall %d: %w", number, err)
}
}
for _, comm := range events.HostInterpreterComms() {
var key [16]byte
copy(key[:], comm)
if err := objects.MonitoredHostComms.Update(key, uint32(1), ebpf.UpdateAny); err != nil {
objects.Close()
return nil, fmt.Errorf("configure write comm %q: %w", comm, err)
}
}
enterLink, err := link.Tracepoint("raw_syscalls", "sys_enter", objects.TraceSysEnter, nil)
if err != nil {
objects.Close()
return nil, fmt.Errorf("attach raw syscall tracepoint: %w", err)
}
exitLink, err := link.Tracepoint("raw_syscalls", "sys_exit", objects.TraceSysExit, nil)
if err != nil {
enterLink.Close()
objects.Close()
return nil, fmt.Errorf("attach raw syscall exit tracepoint: %w", err)
}
reader, err := perf.NewReader(objects.SyscallEvents, 64*4096)
if err != nil {
exitLink.Close()
enterLink.Close()
objects.Close()
return nil, fmt.Errorf("open syscall perf reader: %w", err)
}
return &SyscallSource{objects: objects, links: []link.Link{enterLink, exitLink}, reader: reader, procRoot: procRoot}, nil
}
func (s *SyscallSource) Read() (SecurityEvent, error) {
record, err := s.reader.Read()
if err != nil {
return SecurityEvent{}, err
}
if record.LostSamples != 0 {
return SecurityEvent{}, SyscallLostSamplesError{Count: record.LostSamples}
}
securityEvent, err := decodeSecuritySample(record.RawSample)
if err == nil && securityEvent.Host != nil {
if securityEvent.Host.Path != "" {
securityEvent.Host.Path = resolveProcessPath(
s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD,
securityEvent.Host.Path,
)
} else if securityEvent.Host.Event == "module_load" || securityEvent.Host.Event == "file_write" {
securityEvent.Host.Path = resolveProcessFD(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD)
if securityEvent.Host.Event == "module_load" && securityEvent.Host.Path != "" {
securityEvent.Host.ModuleName = filepath.Base(strings.TrimSuffix(securityEvent.Host.Path, " (deleted)"))
}
}
if securityEvent.Host.Event == "tcp_connect" &&
!isTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD) {
securityEvent.Host = nil
}
if securityEvent.Host != nil &&
(securityEvent.Host.Event == "listen" || securityEvent.Host.Event == "accept") {
local, peer, ok := lookupTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD)
if !ok {
securityEvent.Host = nil
} else {
securityEvent.Host.LocalIP, securityEvent.Host.LocalPort = local.IP, local.Port
securityEvent.Host.PeerIP, securityEvent.Host.PeerPort = peer.IP, peer.Port
}
}
}
return securityEvent, err
}
func decodeSecuritySample(sample []byte) (SecurityEvent, error) {
var raw syscallEventRaw
if len(sample) != binary.Size(raw) {
return SecurityEvent{}, fmt.Errorf("syscall event size %d, want %d", len(sample), binary.Size(raw))
}
if err := binary.Read(bytes.NewReader(sample), binary.NativeEndian, &raw); err != nil {
return SecurityEvent{}, fmt.Errorf("decode syscall event: %w", err)
}
if raw.SyscallID == hostSetuid || raw.SyscallID == hostSetgid {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
if raw.SyscallID == hostSetuid {
event.Event = "setuid"
event.TargetUID = int(raw.Args[0])
} else {
event.Event = "setgid"
event.TargetGID = int(raw.Args[0])
}
return SecurityEvent{Host: &event}, nil
}
if raw.SyscallID >= hostChmod && raw.SyscallID <= hostFchownat {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), Path: cString(raw.Text[:]),
TargetUID: -1, TargetGID: -1,
}
switch raw.SyscallID {
case hostChmod, hostFchmodat:
event.Event = "chmod"
event.Mode = int(raw.Args[0])
case hostChown, hostLchown, hostFchownat:
event.Event = "chown"
event.TargetUID = int(raw.Args[0])
event.TargetGID = int(raw.Args[1])
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[2])}, nil
}
if raw.SyscallID == hostCapset {
event := events.HostEvent{
Event: "capset", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
Capabilities: capabilityNames(raw.Args[0]),
}
return SecurityEvent{Host: &event}, nil
}
if raw.SyscallID == hostFinitModule {
event := events.HostEvent{
Event: "module_load", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil
}
if raw.SyscallID == hostFileWrite {
event := events.HostEvent{
Event: "file_write", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil
}
if raw.SyscallID == hostTCPConnect || raw.SyscallID == hostBind {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
address := socketAddress(raw)
if raw.SyscallID == hostTCPConnect {
event.Event, event.PeerIP, event.PeerPort = "tcp_connect", address, int(raw.Args[1])
} else {
event.Event, event.LocalIP, event.LocalPort = "bind", address, int(raw.Args[1])
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil
}
if raw.SyscallID >= hostListen && raw.SyscallID <= hostAccept4 {
eventName := "accept"
if raw.SyscallID == hostListen {
eventName = "listen"
}
event := events.HostEvent{
Event: eventName, PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil
}
name, ok := syscallNames[raw.SyscallID]
if !ok {
return SecurityEvent{}, fmt.Errorf("unknown canonical syscall id %d", raw.SyscallID)
}
event := events.SyscallEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), Syscall: name, Args: raw.Args,
Text: cString(raw.Text[:]),
}
return SecurityEvent{Syscall: &event}, nil
}
func decodeSyscallSample(sample []byte) (events.SyscallEvent, error) {
securityEvent, err := decodeSecuritySample(sample)
if err != nil {
return events.SyscallEvent{}, err
}
if securityEvent.Syscall == nil {
return events.SyscallEvent{}, fmt.Errorf("sample is a typed host event")
}
return *securityEvent.Syscall, nil
}
func (s *SyscallSource) Close() error {
var failures []error
if s.reader != nil {
failures = append(failures, s.reader.Close())
}
for _, attached := range s.links {
if attached != nil {
failures = append(failures, attached.Close())
}
}
failures = append(failures, s.objects.Close())
return errors.Join(failures...)
}
func monitoredSyscalls() map[uint64]uint32 {
numbers := map[uint64]uint32{
unix.SYS_MPROTECT: syscallMprotect,
unix.SYS_MMAP: syscallMmap,
unix.SYS_MEMFD_CREATE: syscallMemfdCreate,
unix.SYS_PTRACE: syscallPtrace,
unix.SYS_PRCTL: syscallPrctl,
unix.SYS_SECCOMP: syscallSeccomp,
unix.SYS_PROCESS_VM_READV: syscallProcessVMReadv,
unix.SYS_PROCESS_VM_WRITEV: syscallProcessVMWritev,
unix.SYS_MLOCK: syscallMlock,
unix.SYS_MLOCK2: syscallMlock2,
unix.SYS_MLOCKALL: syscallMlockall,
unix.SYS_SETUID: hostSetuid,
unix.SYS_SETGID: hostSetgid,
unix.SYS_FCHMODAT: hostFchmodat,
unix.SYS_FCHOWNAT: hostFchownat,
unix.SYS_CAPSET: hostCapset,
unix.SYS_FINIT_MODULE: hostFinitModule,
unix.SYS_WRITE: hostFileWrite,
unix.SYS_WRITEV: hostFileWrite,
unix.SYS_PWRITE64: hostFileWrite,
unix.SYS_PWRITEV: hostFileWrite,
unix.SYS_PWRITEV2: hostFileWrite,
unix.SYS_CONNECT: hostTCPConnect,
unix.SYS_BIND: hostBind,
unix.SYS_LISTEN: hostListen,
unix.SYS_ACCEPT4: hostAccept4,
}
for number, identifier := range legacyPermissionSyscalls() {
numbers[number] = identifier
}
for number, identifier := range legacyNetworkSyscalls() {
numbers[number] = identifier
}
return numbers
}
func socketAddress(raw syscallEventRaw) string {
switch raw.Args[0] {
case unix.AF_INET:
bytes := make([]byte, 4)
binary.NativeEndian.PutUint32(bytes, uint32(raw.Args[2]))
return net.IP(bytes).String()
case unix.AF_INET6:
bytes := make([]byte, 16)
for index := range bytes {
bytes[index] = byte(raw.Text[index])
}
return net.IP(bytes).String()
default:
return ""
}
}
func capabilityNames(effective uint64) []string {
known := []struct {
bit uint
name string
}{
{0, "CAP_CHOWN"}, {1, "CAP_DAC_OVERRIDE"}, {2, "CAP_DAC_READ_SEARCH"},
{12, "CAP_NET_ADMIN"}, {13, "CAP_NET_RAW"}, {16, "CAP_SYS_MODULE"},
{19, "CAP_SYS_PTRACE"}, {21, "CAP_SYS_ADMIN"},
}
names := make([]string, 0)
for _, capability := range known {
if effective&(uint64(1)<<capability.bit) != 0 {
names = append(names, capability.name)
}
}
return names
}
func resolveProcessPath(procRoot string, pid int, dirFD int64, path string) string {
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
processRoot := filepath.Join(procRoot, fmt.Sprintf("%d", pid))
baseLink := filepath.Join(processRoot, "cwd")
if dirFD >= 0 {
baseLink = filepath.Join(processRoot, "fd", fmt.Sprintf("%d", dirFD))
}
base, err := os.Readlink(baseLink)
if err != nil || !filepath.IsAbs(base) {
return path
}
return filepath.Clean(filepath.Join(base, path))
}
func resolveProcessFD(procRoot string, pid int, fd int64) string {
if fd < 0 {
return ""
}
path, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd)))
if err != nil || !filepath.IsAbs(path) {
return ""
}
return filepath.Clean(path)
}
func isTCPSocket(procRoot string, pid int, fd int64) bool {
_, _, ok := lookupTCPSocket(procRoot, pid, fd)
return ok
}
type socketEndpoint struct {
IP string
Port int
}
func lookupTCPSocket(procRoot string, pid int, fd int64) (socketEndpoint, socketEndpoint, bool) {
target, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd)))
if err != nil || !strings.HasPrefix(target, "socket:[") || !strings.HasSuffix(target, "]") {
return socketEndpoint{}, socketEndpoint{}, false
}
inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")
for _, name := range []string{"tcp", "tcp6"} {
file, err := os.Open(filepath.Join(procRoot, "net", name))
if err != nil {
continue
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) > 9 && fields[9] == inode {
local, localErr := decodeProcEndpoint(fields[1], name == "tcp6")
peer, peerErr := decodeProcEndpoint(fields[2], name == "tcp6")
file.Close()
return local, peer, localErr == nil && peerErr == nil
}
}
file.Close()
}
return socketEndpoint{}, socketEndpoint{}, false
}
func decodeProcEndpoint(value string, ipv6 bool) (socketEndpoint, error) {
addressHex, portHex, ok := strings.Cut(value, ":")
if !ok {
return socketEndpoint{}, fmt.Errorf("invalid proc endpoint %q", value)
}
address, err := hex.DecodeString(addressHex)
if err != nil || len(address) != 4 && len(address) != 16 {
return socketEndpoint{}, fmt.Errorf("invalid proc address %q", addressHex)
}
if ipv6 {
for offset := 0; offset < len(address); offset += 4 {
slices.Reverse(address[offset : offset+4])
}
} else {
slices.Reverse(address)
}
port, err := strconv.ParseUint(portHex, 16, 16)
if err != nil {
return socketEndpoint{}, err
}
return socketEndpoint{IP: net.IP(address).String(), Port: int(port)}, nil
}
var _ io.Closer = (*SyscallSource)(nil)