feat(go): advance validation sidecar toward production
This commit is contained in:
parent
6a06eba255
commit
f85c2e831a
92 changed files with 8881 additions and 91 deletions
476
go-agent/internal/ebpfsource/syscall.go
Normal file
476
go-agent/internal/ebpfsource/syscall.go
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue