diff --git a/daemon/conman/connection.go b/daemon/conman/connection.go index fe9f671766..7ebaebcbb2 100644 --- a/daemon/conman/connection.go +++ b/daemon/conman/connection.go @@ -323,6 +323,7 @@ func (c *Connection) String() string { func (c *Connection) Serialize() *protocol.Connection { c.Process.RLock() defer c.Process.RUnlock() + return &protocol.Connection{ Protocol: c.Protocol, SrcIp: c.SrcIP.String(), @@ -334,7 +335,7 @@ func (c *Connection) Serialize() *protocol.Connection { ProcessId: uint32(c.Process.ID), ProcessPath: c.Process.Path, ProcessArgs: c.Process.Args, - ProcessEnv: c.Process.Env, + ProcessEnv: c.Process.SerializeEnv(), ProcessCwd: c.Process.CWD, ProcessChecksums: c.Process.Checksums, ProcessTree: c.Process.Tree, diff --git a/daemon/conman/connection_test.go b/daemon/conman/connection_test.go index 4c76a1a944..e80ce2baad 100644 --- a/daemon/conman/connection_test.go +++ b/daemon/conman/connection_test.go @@ -5,6 +5,8 @@ import ( "net" "testing" + "github.com/evilsocket/opensnitch/daemon/netstat" + "github.com/evilsocket/opensnitch/daemon/procmon" "github.com/google/gopacket" "github.com/google/gopacket/layers" @@ -125,3 +127,36 @@ func TestParseUDPDirection(t *testing.T) { t.Fail() } } + +func TestConnectionSerializeIncludesContainerEnv(t *testing.T) { + conn := &Connection{ + Protocol: "tcp", + DstHost: "example.org", + SrcIP: net.IP{192, 168, 1, 100}, + DstIP: net.IP{1, 1, 1, 1}, + SrcPort: 12345, + DstPort: 443, + Entry: &netstat.Entry{UserId: 1000}, + Process: procmon.NewProcessEmpty(1234, "curl"), + } + conn.Process.Env = map[string]string{"PATH": "/usr/bin"} + conn.Process.Container = &procmon.ContainerContext{ + IsContainer: true, + Runtime: "Docker", + CgroupPath: "/docker/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ContainerID: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ContainerName: "web", + } + + serialized := conn.Serialize() + + if serialized.ProcessEnv["_OS_CONTAINER_RUNTIME"] != "Docker" { + t.Fatalf("ProcessEnv[_OS_CONTAINER_RUNTIME] = %q, want %q", serialized.ProcessEnv["_OS_CONTAINER_RUNTIME"], "Docker") + } + if serialized.ProcessEnv["_OS_CONTAINER_NAME"] != "web" { + t.Fatalf("ProcessEnv[_OS_CONTAINER_NAME] = %q, want %q", serialized.ProcessEnv["_OS_CONTAINER_NAME"], "web") + } + if serialized.ProcessEnv["PATH"] != "/usr/bin" { + t.Fatalf("ProcessEnv[PATH] = %q, want %q", serialized.ProcessEnv["PATH"], "/usr/bin") + } +} diff --git a/daemon/data/default-config.json b/daemon/data/default-config.json index baa5d7ee2e..0f543a022e 100644 --- a/daemon/data/default-config.json +++ b/daemon/data/default-config.json @@ -44,5 +44,39 @@ "Internal": { "GCPercent": 100, "FlushConnsOnStart": true - } + }, + "cgroup_monitors": [ + { + "name": "docker", + "enabled": true, + "base_path": "docker", + "regex": "docker[/-](?P[a-f0-9]{64})", + "display_name": "Docker", + "container_name_regex": "docker-(?P[a-zA-Z0-9._-]+?)-[a-f0-9]{64}" + }, + { + "name": "podman_rootless", + "enabled": true, + "base_path": "libpod", + "regex": "libpod-(?P[a-f0-9]{64})\\.scope", + "display_name": "Podman (Rootless)", + "container_name_regex": "libpod-(?P.+?)-[a-f0-9]{64}" + }, + { + "name": "containerd", + "enabled": true, + "base_path": "containerd|crio", + "regex": "(?:containerd|crio)-(?P[a-f0-9]{64})", + "display_name": "Containerd", + "container_name_regex": "(?:containerd|crio)-(?P.+?)-[a-f0-9]{64}" + }, + { + "name": "k8s", + "enabled": false, + "base_path": "/kubepods", + "regex": "kubepods.*pod(?P[a-f0-9_-]+)[./].*(?P[a-f0-9]{64})", + "display_name": "Kubernetes", + "container_name_regex": "kubepods.*pod(?P[a-f0-9_-]+)" + } + ] } diff --git a/daemon/main.go b/daemon/main.go index eff9220b1c..f7b7a36a0e 100644 --- a/daemon/main.go +++ b/daemon/main.go @@ -47,6 +47,7 @@ import ( "github.com/evilsocket/opensnitch/daemon/log/loggers" "github.com/evilsocket/opensnitch/daemon/netfilter" "github.com/evilsocket/opensnitch/daemon/netlink" + "github.com/evilsocket/opensnitch/daemon/procmon" "github.com/evilsocket/opensnitch/daemon/procmon/ebpf" "github.com/evilsocket/opensnitch/daemon/procmon/monitor" "github.com/evilsocket/opensnitch/daemon/rule" @@ -587,6 +588,11 @@ func main() { if err != nil { log.Fatal("%s", err) } + if len(cfg.CgroupMonitors) == 0 { + cfg.CgroupMonitors = procmon.DefaultCgroupMonitors() + } + + procmon.InitCgroupMonitors(cfg.CgroupMonitors) if cfg.Rules.Path == "" { cfg.Rules.Path = rule.DefaultPath diff --git a/daemon/procmon/container.go b/daemon/procmon/container.go new file mode 100644 index 0000000000..68ee0f3190 --- /dev/null +++ b/daemon/procmon/container.go @@ -0,0 +1,279 @@ +package procmon + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + + "github.com/evilsocket/opensnitch/daemon/log" +) + +type CgroupMonitorConfig struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + BasePath string `json:"base_path"` + RegexStr string `json:"regex"` + DisplayName string `json:"display_name"` + ContainerNameRegex string `json:"container_name_regex,omitempty"` + compiledRe *regexp.Regexp + compiledNameRe *regexp.Regexp +} + +type ContainerContext struct { + IsContainer bool `json:"is_container"` + Runtime string `json:"runtime,omitempty"` + CgroupPath string `json:"cgroup_path,omitempty"` + ContainerID string `json:"container_id,omitempty"` + ContainerName string `json:"container_name,omitempty"` // Derived from runtime metadata/env vars +} + +const maxDockerContainerNameCacheEntries = 1024 + +type dockerContainerNameCacheStore struct { + mu sync.Mutex + maxEntries int + entries map[string]string + order []string +} + +func newDockerContainerNameCache(maxEntries int) *dockerContainerNameCacheStore { + if maxEntries <= 0 { + maxEntries = maxDockerContainerNameCacheEntries + } + return &dockerContainerNameCacheStore{ + maxEntries: maxEntries, + entries: make(map[string]string, maxEntries), + order: make([]string, 0, maxEntries), + } +} + +func (c *dockerContainerNameCacheStore) Load(containerID string) (string, bool) { + if c == nil { + return "", false + } + c.mu.Lock() + defer c.mu.Unlock() + name, ok := c.entries[containerID] + return name, ok +} + +func (c *dockerContainerNameCacheStore) Store(containerID, name string) { + if c == nil || containerID == "" || name == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.entries[containerID]; !ok { + if len(c.entries) >= c.maxEntries { + if len(c.order) == 0 { + return + } + oldest := c.order[0] + delete(c.entries, oldest) + c.order = c.order[1:] + } + c.order = append(c.order, containerID) + } + c.entries[containerID] = name +} + +func (c *dockerContainerNameCacheStore) Reset() { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.entries = make(map[string]string, c.maxEntries) + c.order = c.order[:0] +} + +var cgroupMonitors []*CgroupMonitorConfig +var dockerContainerNameCache = newDockerContainerNameCache(maxDockerContainerNameCacheEntries) + +var dockerContainerNameLookup = lookupDockerContainerNameFromConfig + +func lookupDockerContainerName(containerID string) string { + if containerID == "" { + return "" + } + + if cached, ok := dockerContainerNameCache.Load(containerID); ok { + return cached + } + + name := dockerContainerNameLookup(containerID) + if name == "" { + return "" + } + + dockerContainerNameCache.Store(containerID, name) + return name +} + +func lookupDockerContainerNameFromConfig(containerID string) string { + configPath := filepath.Join("/var/lib/docker/containers", containerID, "config.v2.json") + data, err := os.ReadFile(configPath) + if err != nil { + return "" + } + + var cfg struct { + Name string `json:"Name"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return "" + } + + return strings.TrimPrefix(strings.TrimSpace(cfg.Name), "/") +} + +// DefaultCgroupMonitors returns the built-in cgroup monitor configuration. +func DefaultCgroupMonitors() []*CgroupMonitorConfig { + return []*CgroupMonitorConfig{ + { + Name: "docker", + Enabled: true, + BasePath: "docker", + RegexStr: "docker[/-](?P[a-f0-9]{64})", + DisplayName: "Docker", + ContainerNameRegex: "docker-(?P[a-zA-Z0-9._-]+?)-[a-f0-9]{64}", + }, + { + Name: "podman_rootless", + Enabled: true, + BasePath: "libpod", + RegexStr: "libpod-(?P[a-f0-9]{64})\\.scope", + DisplayName: "Podman (Rootless)", + ContainerNameRegex: "libpod-(?P.+?)-[a-f0-9]{64}", + }, + { + Name: "containerd", + Enabled: true, + BasePath: "containerd|crio", + RegexStr: "(?:containerd|crio)-(?P[a-f0-9]{64})", + DisplayName: "Containerd", + ContainerNameRegex: "(?:containerd|crio)-(?P.+?)-[a-f0-9]{64}", + }, + { + Name: "k8s", + Enabled: false, + BasePath: "/kubepods", + RegexStr: "kubepods.*pod(?P[a-f0-9_-]+)[./].*(?P[a-f0-9]{64})", + DisplayName: "Kubernetes", + ContainerNameRegex: "kubepods.*pod(?P[a-f0-9_-]+)", + }, + } +} + +// InitCgroupMonitors initializes the global cgroup monitors config and compiles regexes +func InitCgroupMonitors(monitors []*CgroupMonitorConfig) { + cgroupMonitors = monitors + for _, m := range cgroupMonitors { + if !m.Enabled { + continue + } + re, err := regexp.Compile(m.RegexStr) + if err != nil { + log.Error("Failed to compile regex for cgroup monitor %s: %v", m.Name, err) + m.Enabled = false + continue + } + m.compiledRe = re + + // Compile container name regex if provided + if m.ContainerNameRegex != "" { + nameRe, err := regexp.Compile(m.ContainerNameRegex) + if err != nil { + log.Error("Failed to compile container name regex for cgroup monitor %s: %v", m.Name, err) + // Don't disable the monitor, just skip the name extraction + } else { + m.compiledNameRe = nameRe + } + } + } +} + +// ParseContainer checks if a process belongs to a container by reading its cgroup file. +func ParseContainer(cgroupPath string, env map[string]string) *ContainerContext { + if len(cgroupMonitors) == 0 { + return nil + } + + file, err := os.Open(cgroupPath) + if err != nil { + return nil + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + // Strip hierarchy prefixes for cgroup v1 (e.g. 11:memory:/docker/...) + // and cgroup v2 (e.g. 0::/user.slice/...) + parts := strings.SplitN(line, ":", 3) + var cPath string + if len(parts) == 3 { + cPath = parts[2] + } else { + cPath = line + } + + for _, monitor := range cgroupMonitors { + if !monitor.Enabled { + continue + } + + if monitor.BasePath == "" || strings.Contains(cPath, monitor.BasePath) || (monitor.Name == "containerd" && (strings.Contains(cPath, "containerd") || strings.Contains(cPath, "crio"))) { + matches := monitor.compiledRe.FindStringSubmatch(cPath) + if matches != nil { + idIndex := monitor.compiledRe.SubexpIndex("id") + if idIndex != -1 { + containerID := matches[idIndex] + + // Determine container name from env, runtime metadata or cgroup path + // Priority: + // 1. CONTAINER_NAME env var (explicit name) + // 2. Runtime specific metadata (Docker config) + // 3. Extract from cgroup path regex + // 4. Fall back to container ID short form + containerName := "" + if name, ok := env["CONTAINER_NAME"]; ok { + containerName = name + } else if monitor.Name == "docker" { + containerName = lookupDockerContainerName(containerID) + } else if monitor.compiledNameRe != nil { + // Try to extract container name from cgroup path + nameMatches := monitor.compiledNameRe.FindStringSubmatch(cPath) + if nameMatches != nil { + nameIndex := monitor.compiledNameRe.SubexpIndex("name") + if nameIndex != -1 { + containerName = nameMatches[nameIndex] + } + } + } + + // Fallback to container ID short form if no name found + if containerName == "" && len(containerID) >= 7 { + containerName = containerID[:7] + } + + return &ContainerContext{ + IsContainer: true, + Runtime: monitor.DisplayName, + CgroupPath: cPath, + ContainerID: containerID, + ContainerName: containerName, + } + } + } + } + } + } + return nil +} diff --git a/daemon/procmon/container_test.go b/daemon/procmon/container_test.go new file mode 100644 index 0000000000..597971d0bc --- /dev/null +++ b/daemon/procmon/container_test.go @@ -0,0 +1,274 @@ +package procmon + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseContainer(t *testing.T) { + oldMonitors := cgroupMonitors + oldDockerLookup := dockerContainerNameLookup + t.Cleanup(func() { + cgroupMonitors = oldMonitors + dockerContainerNameLookup = oldDockerLookup + dockerContainerNameCache.Reset() + }) + dockerContainerNameCache.Reset() + + monitors := []*CgroupMonitorConfig{ + { + Name: "docker", + Enabled: true, + BasePath: "docker", + RegexStr: "docker[/-](?P[a-f0-9]{64})", + DisplayName: "Docker", + ContainerNameRegex: "docker-(?P[a-zA-Z0-9._-]+?)-[a-f0-9]{64}", + }, + { + Name: "podman_rootless", + Enabled: true, + BasePath: "libpod", + RegexStr: "libpod-(?P[a-f0-9]{64})\\.scope", + DisplayName: "Podman (Rootless)", + ContainerNameRegex: "libpod-(?P.+?)-[a-f0-9]{64}", + }, + { + Name: "containerd", + Enabled: true, + BasePath: "containerd", + RegexStr: "(?:containerd|crio)-(?P[a-f0-9]{64})", + DisplayName: "Containerd", + ContainerNameRegex: "(?:containerd|crio)-(?P.+?)-[a-f0-9]{64}", + }, + } + InitCgroupMonitors(monitors) + + dockerID := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + podmanID := "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + containerdID := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + + dockerContainerNameLookup = func(containerID string) string { + if containerID == dockerID { + return "foo" + } + return "" + } + + tests := []struct { + name string + content string + env map[string]string + wantNil bool + wantRuntime string + wantCgroup string + wantID string + wantName string + }{ + { + name: "docker cgroup v1 prefers container name", + content: "11:memory:/docker/" + dockerID + "\n", + env: map[string]string{"CONTAINER_NAME": "web"}, + wantRuntime: "Docker", + wantCgroup: "/docker/" + dockerID, + wantID: dockerID, + wantName: "web", + }, + { + name: "podman cgroup v2 ignores hostname and falls back to short id", + content: "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-" + podmanID + ".scope\n", + env: map[string]string{"HOSTNAME": "pod-a"}, + wantRuntime: "Podman (Rootless)", + wantCgroup: "/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-" + podmanID + ".scope", + wantID: podmanID, + wantName: "abcdef0", + }, + { + name: "docker cgroup v2 gets name from docker metadata", + content: "0::/system.slice/docker-" + dockerID + ".scope\n", + env: map[string]string{"HOSTNAME": "adrian-QEMU-Virtual-Machine"}, + wantRuntime: "Docker", + wantCgroup: "/system.slice/docker-" + dockerID + ".scope", + wantID: dockerID, + wantName: "foo", + }, + { + name: "containerd falls back to short id when env is empty", + content: "0::/system.slice/containerd.service/containerd-" + containerdID + ".scope\n", + env: map[string]string{}, + wantRuntime: "Containerd", + wantCgroup: "/system.slice/containerd.service/containerd-" + containerdID + ".scope", + wantID: containerdID, + wantName: "fedcba9", + }, + { + name: "crio cgroup is recognized by the containerd monitor", + content: "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podabc.slice/crio-" + containerdID + ".scope\n", + env: map[string]string{}, + wantRuntime: "Containerd", + wantCgroup: "/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podabc.slice/crio-" + containerdID + ".scope", + wantID: containerdID, + wantName: "fedcba9", + }, + { + name: "returns nil when no monitor matches", + content: "0::/user.slice/user-1000.slice/session-2.scope\n", + env: map[string]string{"HOSTNAME": "host"}, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cgroupPath := writeTempCgroupFile(t, tt.content) + got := ParseContainer(cgroupPath, tt.env) + + if tt.wantNil { + if got != nil { + t.Fatalf("ParseContainer() = %#v, want nil", got) + } + return + } + + if got == nil { + t.Fatal("ParseContainer() = nil, want container context") + } + if !got.IsContainer { + t.Fatal("ParseContainer() returned a non-container context") + } + if got.Runtime != tt.wantRuntime { + t.Fatalf("Runtime = %q, want %q", got.Runtime, tt.wantRuntime) + } + if got.CgroupPath != tt.wantCgroup { + t.Fatalf("CgroupPath = %q, want %q", got.CgroupPath, tt.wantCgroup) + } + if got.ContainerID != tt.wantID { + t.Fatalf("ContainerID = %q, want %q", got.ContainerID, tt.wantID) + } + if got.ContainerName != tt.wantName { + t.Fatalf("ContainerName = %q, want %q", got.ContainerName, tt.wantName) + } + }) + } +} + +func TestParseContainerReturnsNilWithoutMonitors(t *testing.T) { + oldMonitors := cgroupMonitors + cgroupMonitors = nil + t.Cleanup(func() { + cgroupMonitors = oldMonitors + }) + + cgroupPath := writeTempCgroupFile(t, "11:memory:/docker/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n") + if got := ParseContainer(cgroupPath, map[string]string{"CONTAINER_NAME": "web"}); got != nil { + t.Fatalf("ParseContainer() = %#v, want nil when monitors are not initialized", got) + } +} + +func TestInitCgroupMonitorsDisablesInvalidRegex(t *testing.T) { + oldMonitors := cgroupMonitors + t.Cleanup(func() { + cgroupMonitors = oldMonitors + }) + + monitors := []*CgroupMonitorConfig{ + { + Name: "broken", + Enabled: true, + BasePath: "docker", + RegexStr: "(", + DisplayName: "Broken", + }, + } + + InitCgroupMonitors(monitors) + + var brokenMonitor *CgroupMonitorConfig + for _, m := range cgroupMonitors { + if m.Name == "broken" { + brokenMonitor = m + break + } + } + + if brokenMonitor == nil { + t.Fatal("broken monitor not found") + } + if brokenMonitor.Enabled { + t.Fatal("InitCgroupMonitors() left an invalid regex monitor enabled") + } + if brokenMonitor.compiledRe != nil { + t.Fatal("InitCgroupMonitors() compiled an invalid regex") + } + + cgroupPath := writeTempCgroupFile(t, "11:memory:/docker/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n") + if got := ParseContainer(cgroupPath, map[string]string{"CONTAINER_NAME": "web"}); got != nil { + t.Fatalf("ParseContainer() = %#v, want nil when the only monitor is invalid", got) + } +} + +func TestDefaultCgroupMonitors(t *testing.T) { + monitors := DefaultCgroupMonitors() + if len(monitors) == 0 { + t.Fatal("DefaultCgroupMonitors() returned no monitors") + } + + names := make(map[string]bool) + for _, m := range monitors { + names[m.Name] = true + } + + if !names["docker"] { + t.Fatal("DefaultCgroupMonitors() is missing docker") + } + if !names["podman_rootless"] { + t.Fatal("DefaultCgroupMonitors() is missing podman_rootless") + } + if !names["containerd"] { + t.Fatal("DefaultCgroupMonitors() is missing containerd") + } +} + +func TestLookupDockerContainerNameDoesNotCacheMisses(t *testing.T) { + oldDockerLookup := dockerContainerNameLookup + t.Cleanup(func() { + dockerContainerNameLookup = oldDockerLookup + dockerContainerNameCache.Reset() + }) + dockerContainerNameCache.Reset() + + containerID := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + lookupCalls := 0 + dockerContainerNameLookup = func(id string) string { + lookupCalls++ + if id != containerID { + t.Fatalf("lookup called with %q, want %q", id, containerID) + } + return "" + } + + if got := lookupDockerContainerName(containerID); got != "" { + t.Fatalf("lookupDockerContainerName() = %q, want empty string", got) + } + if got := lookupDockerContainerName(containerID); got != "" { + t.Fatalf("lookupDockerContainerName() second call = %q, want empty string", got) + } + if lookupCalls != 2 { + t.Fatalf("lookupDockerContainerName() called lookup %d times, want 2", lookupCalls) + } + + if _, ok := dockerContainerNameCache.Load(containerID); ok { + t.Fatal("lookupDockerContainerName() cached a missing container name") + } +} + +func writeTempCgroupFile(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "cgroup") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%q) failed: %v", path, err) + } + return path +} diff --git a/daemon/procmon/details.go b/daemon/procmon/details.go index 0f0175d085..2a407f5539 100644 --- a/daemon/procmon/details.go +++ b/daemon/procmon/details.go @@ -117,6 +117,7 @@ func (p *Process) GetDetails() error { // we need to load the env variables now, in order to be used with the rules. p.ReadEnv() + p.Container = ParseContainer(p.pathCgroup, p.Env) return nil } diff --git a/daemon/procmon/process.go b/daemon/procmon/process.go index a75fddcf36..6a553e3555 100644 --- a/daemon/procmon/process.go +++ b/daemon/procmon/process.go @@ -91,6 +91,9 @@ type Process struct { pathMaps string pathMem string pathIO string + pathCgroup string + + Container *ContainerContext // Path is the absolute path to the binary Path string @@ -151,6 +154,7 @@ func NewProcessEmpty(pid int, comm string) *Process { p.pathMem = core.ConcatStrings(p.pathProc, "/mem") p.pathFd = core.ConcatStrings(p.pathProc, "/fd/") p.pathIO = core.ConcatStrings(p.pathProc, "/io") + p.pathCgroup = core.ConcatStrings(p.pathProc, "/cgroup") return p } @@ -201,6 +205,26 @@ func (p *Process) RUnlock() { p.mu.RUnlock() } +func (p *Process) SerializeEnv() map[string]string { + if p == nil { + return nil + } + + env := p.Env + if p.Container != nil && p.Container.IsContainer { + env = make(map[string]string, len(p.Env)+4) + for k, v := range p.Env { + env[k] = v + } + env["_OS_CONTAINER_RUNTIME"] = p.Container.Runtime + env["_OS_CONTAINER_CGROUP_PATH"] = p.Container.CgroupPath + env["_OS_CONTAINER_ID"] = p.Container.ContainerID + env["_OS_CONTAINER_NAME"] = p.Container.ContainerName + } + + return env +} + //Serialize transforms a Process object to gRPC protocol object func (p *Process) Serialize() *protocol.Process { ioStats := p.IOStats @@ -219,7 +243,7 @@ func (p *Process) Serialize() *protocol.Process { Comm: p.Comm, Path: p.Path, Args: p.Args, - Env: p.Env, + Env: p.SerializeEnv(), Cwd: p.CWD, Checksums: p.Checksums, IoReads: uint64(ioStats.RChar), diff --git a/daemon/rule/operator.go b/daemon/rule/operator.go index e81c240567..a89b26a520 100644 --- a/daemon/rule/operator.go +++ b/daemon/rule/operator.go @@ -48,6 +48,8 @@ const ( OpProcessEnvPrefixLen = 12 OpProcessHashMD5 = Operand("process.hash.md5") OpProcessHashSHA1 = Operand("process.hash.sha1") + OpCgroupPath = Operand("cgroup.path") + OpCgroupContainerID = Operand("cgroup.container_id") OpUserID = Operand("user.id") OpUserName = Operand("user.name") OpSrcIP = Operand("source.ip") @@ -523,6 +525,14 @@ func (o *Operator) Match(con *conman.Connection, hasChecksums bool) bool { } } return false + } else if o.Operand == OpCgroupPath { + if con.Process.Container != nil && con.Process.Container.IsContainer { + return o.cb(con.Process.Container.CgroupPath) + } + } else if o.Operand == OpCgroupContainerID { + if con.Process.Container != nil && con.Process.Container.IsContainer { + return o.cb(con.Process.Container.ContainerID) + } } else if strings.HasPrefix(string(o.Operand), string(OpProcessEnvPrefix)) { envVarName := core.Trim(string(o.Operand[OpProcessEnvPrefixLen:])) envVarValue, _ := con.Process.Env[envVarName] diff --git a/daemon/rule/operator_test.go b/daemon/rule/operator_test.go index 62509d4a3f..63c36c6f90 100644 --- a/daemon/rule/operator_test.go +++ b/daemon/rule/operator_test.go @@ -649,6 +649,95 @@ func TestNewOperatorSimple(t *testing.T) { } }) + t.Run("Operator Simple cgroup.path", func(t *testing.T) { + oldContainer := conn.Process.Container + defer func() { + conn.Process.Container = oldContainer + }() + + conn.Process.Container = &procmon.ContainerContext{ + IsContainer: true, + CgroupPath: "/docker/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + } + + opSimple, err = NewOperator(Simple, false, OpCgroupPath, "/docker/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", list) + if err != nil { + t.Error("NewOperator simple cgroup.path err should be nil: ", err) + t.Fail() + } + if err = opSimple.Compile(); err != nil { + t.Error("NewOperator simple cgroup.path Compile() err: ", err) + t.Fail() + } + if opSimple.Match(conn, false) == false { + t.Error("Test NewOperator() simple cgroup.path doesn't match") + t.Fail() + } + }) + + t.Run("Operator Simple cgroup.container_id", func(t *testing.T) { + oldContainer := conn.Process.Container + defer func() { + conn.Process.Container = oldContainer + }() + + conn.Process.Container = &procmon.ContainerContext{ + IsContainer: true, + ContainerID: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + } + + opSimple, err = NewOperator(Simple, false, OpCgroupContainerID, "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", list) + if err != nil { + t.Error("NewOperator simple cgroup.container_id err should be nil: ", err) + t.Fail() + } + if err = opSimple.Compile(); err != nil { + t.Error("NewOperator simple cgroup.container_id Compile() err: ", err) + t.Fail() + } + if opSimple.Match(conn, false) == false { + t.Error("Test NewOperator() simple cgroup.container_id doesn't match") + t.Fail() + } + }) + + t.Run("Operator Simple cgroup operands return false without container context", func(t *testing.T) { + oldContainer := conn.Process.Container + defer func() { + conn.Process.Container = oldContainer + }() + + conn.Process.Container = nil + + opPath, err := NewOperator(Simple, false, OpCgroupPath, "/docker/example", list) + if err != nil { + t.Error("NewOperator simple cgroup.path err should be nil: ", err) + t.Fail() + } + if err = opPath.Compile(); err != nil { + t.Error("NewOperator simple cgroup.path Compile() err: ", err) + t.Fail() + } + if opPath.Match(conn, false) == true { + t.Error("Test NewOperator() simple cgroup.path should not match without container context") + t.Fail() + } + + opID, err := NewOperator(Simple, false, OpCgroupContainerID, "0123456789abcdef", list) + if err != nil { + t.Error("NewOperator simple cgroup.container_id err should be nil: ", err) + t.Fail() + } + if err = opID.Compile(); err != nil { + t.Error("NewOperator simple cgroup.container_id Compile() err: ", err) + t.Fail() + } + if opID.Match(conn, false) == true { + t.Error("Test NewOperator() simple cgroup.container_id should not match without container context") + t.Fail() + } + }) + restoreConnection() } diff --git a/daemon/ui/config/config.go b/daemon/ui/config/config.go index d325f79b5c..231c4cc880 100644 --- a/daemon/ui/config/config.go +++ b/daemon/ui/config/config.go @@ -9,6 +9,7 @@ import ( "github.com/evilsocket/opensnitch/daemon/log" "github.com/evilsocket/opensnitch/daemon/log/loggers" + "github.com/evilsocket/opensnitch/daemon/procmon" "github.com/evilsocket/opensnitch/daemon/procmon/audit" "github.com/evilsocket/opensnitch/daemon/procmon/ebpf" "github.com/evilsocket/opensnitch/daemon/statistics" @@ -76,19 +77,20 @@ type ( // Config holds the values loaded from configFile type Config struct { - LogLevel *int32 `json:"LogLevel"` - Firewall string `json:"Firewall"` - DefaultAction string `json:"DefaultAction"` - DefaultDuration string `json:"DefaultDuration"` - ProcMonitorMethod string `json:"ProcMonitorMethod"` - FwOptions FwOptions `json:"FwOptions"` - Audit audit.Config `json:"Audit"` - Ebpf ebpf.Config `json:"Ebpf"` - Server ServerConfig `json:"Server"` - Rules RulesOptions `json:"Rules"` - Internal InternalOptions `json:"Internal"` - Stats statistics.StatsConfig `json:"Stats"` - TasksOptions TasksOptions `json:"Tasks"` + LogLevel *int32 `json:"LogLevel"` + Firewall string `json:"Firewall"` + DefaultAction string `json:"DefaultAction"` + DefaultDuration string `json:"DefaultDuration"` + ProcMonitorMethod string `json:"ProcMonitorMethod"` + FwOptions FwOptions `json:"FwOptions"` + Audit audit.Config `json:"Audit"` + Ebpf ebpf.Config `json:"Ebpf"` + Server ServerConfig `json:"Server"` + Rules RulesOptions `json:"Rules"` + Internal InternalOptions `json:"Internal"` + Stats statistics.StatsConfig `json:"Stats"` + TasksOptions TasksOptions `json:"Tasks"` + CgroupMonitors []*procmon.CgroupMonitorConfig `json:"cgroup_monitors"` InterceptUnknown bool `json:"InterceptUnknown"` LogUTC bool `json:"LogUTC"` diff --git a/daemon/ui/testdata/default-config.json b/daemon/ui/testdata/default-config.json index 801f331554..25498dc332 100644 --- a/daemon/ui/testdata/default-config.json +++ b/daemon/ui/testdata/default-config.json @@ -43,5 +43,39 @@ "MaxEvents": 150, "MaxStats": 25, "Workers": 6 - } + }, + "cgroup_monitors": [ + { + "name": "docker", + "enabled": true, + "base_path": "docker", + "regex": "docker[/-](?P[a-f0-9]{64})", + "display_name": "Docker", + "container_name_regex": "docker-(?P[a-zA-Z0-9._-]+?)-[a-f0-9]{64}" + }, + { + "name": "podman_rootless", + "enabled": true, + "base_path": "libpod", + "regex": "libpod-(?P[a-f0-9]{64})\\.scope", + "display_name": "Podman (Rootless)", + "container_name_regex": "libpod-(?P.+?)-[a-f0-9]{64}" + }, + { + "name": "containerd", + "enabled": true, + "base_path": "containerd|crio", + "regex": "(?:containerd|crio)-(?P[a-f0-9]{64})", + "display_name": "Containerd", + "container_name_regex": "(?:containerd|crio)-(?P.+?)-[a-f0-9]{64}" + }, + { + "name": "k8s", + "enabled": false, + "base_path": "/kubepods", + "regex": "kubepods.*pod(?P[a-f0-9_-]+)[./].*(?P[a-f0-9]{64})", + "display_name": "Kubernetes", + "container_name_regex": "kubepods.*pod(?P[a-f0-9_-]+)" + } + ] } diff --git a/daemon/ui/testdata/default-config.json.orig b/daemon/ui/testdata/default-config.json.orig index 801f331554..25498dc332 100644 --- a/daemon/ui/testdata/default-config.json.orig +++ b/daemon/ui/testdata/default-config.json.orig @@ -43,5 +43,39 @@ "MaxEvents": 150, "MaxStats": 25, "Workers": 6 - } + }, + "cgroup_monitors": [ + { + "name": "docker", + "enabled": true, + "base_path": "docker", + "regex": "docker[/-](?P[a-f0-9]{64})", + "display_name": "Docker", + "container_name_regex": "docker-(?P[a-zA-Z0-9._-]+?)-[a-f0-9]{64}" + }, + { + "name": "podman_rootless", + "enabled": true, + "base_path": "libpod", + "regex": "libpod-(?P[a-f0-9]{64})\\.scope", + "display_name": "Podman (Rootless)", + "container_name_regex": "libpod-(?P.+?)-[a-f0-9]{64}" + }, + { + "name": "containerd", + "enabled": true, + "base_path": "containerd|crio", + "regex": "(?:containerd|crio)-(?P[a-f0-9]{64})", + "display_name": "Containerd", + "container_name_regex": "(?:containerd|crio)-(?P.+?)-[a-f0-9]{64}" + }, + { + "name": "k8s", + "enabled": false, + "base_path": "/kubepods", + "regex": "kubepods.*pod(?P[a-f0-9_-]+)[./].*(?P[a-f0-9]{64})", + "display_name": "Kubernetes", + "container_name_regex": "kubepods.*pod(?P[a-f0-9_-]+)" + } + ] } diff --git a/ui/opensnitch/config.py b/ui/opensnitch/config.py index 93df00c1c5..fb8984c156 100644 --- a/ui/opensnitch/config.py +++ b/ui/opensnitch/config.py @@ -13,6 +13,8 @@ class Config: OPERAND_PROCESS_ID = "process.id" OPERAND_PROCESS_PATH = "process.path" + OPERAND_CGROUP_PATH = "cgroup.path" + OPERAND_CGROUP_ID = "cgroup.container_id" OPERAND_PROCESS_COMMAND = "process.command" OPERAND_PROCESS_ENV = "process.env." OPERAND_PROCESS_HASH_MD5 = "process.hash.md5" diff --git a/ui/opensnitch/dialogs/prompt/constants.py b/ui/opensnitch/dialogs/prompt/constants.py index 7ecba064fe..f6ae9bac25 100644 --- a/ui/opensnitch/dialogs/prompt/constants.py +++ b/ui/opensnitch/dialogs/prompt/constants.py @@ -21,6 +21,8 @@ FIELD_DST_HOST = "simple_host" FIELD_APPIMAGE = "appimage_path" FIELD_SNAP = "snap_path" +FIELD_CGROUP_PATH = "cgroup_path" +FIELD_CGROUP_ID = "cgroup_container_id" TARGET_IDX_PROC_PATH = 0 TARGET_IDX_PROC_CMDLINE = 1 diff --git a/ui/opensnitch/dialogs/prompt/dialog.py b/ui/opensnitch/dialogs/prompt/dialog.py index 139fdab147..311826a57a 100644 --- a/ui/opensnitch/dialogs/prompt/dialog.py +++ b/ui/opensnitch/dialogs/prompt/dialog.py @@ -500,6 +500,16 @@ def _display_checksums_warning(self, peer, con): def _render_connection(self, con): app_name, app_icon, description, _ = self._apps_parser.get_info_by_path(con.process_path, "terminal") app_args = " ".join(con.process_args) + + if "_OS_CONTAINER_RUNTIME" in con.process_env: + container_runtime = con.process_env["_OS_CONTAINER_RUNTIME"] + container_name = con.process_env.get("_OS_CONTAINER_NAME", "") + container_id = con.process_env.get("_OS_CONTAINER_ID", "") + if container_name: + description = f"[{container_runtime}] Container: {container_name}" + else: + description = f"[{container_runtime}] Container: {container_id[:12]}" + utils.set_app_description(self.appDescriptionLabel, description) utils.set_app_path(self.appPathLabel, app_name, app_args, con) utils.set_app_args(self.argsLabel, app_name, app_args) @@ -557,6 +567,7 @@ def _render_connection(self, con): self.whatIPCombo.clear() utils.add_fixed_options_to_combo(self.whatCombo, con, uid) + utils.add_container_options_to_combo(self.whatCombo, con) if con.process_path.startswith(constants.APPIMAGE_PREFIX): utils.add_appimage_pattern_to_combo(self.whatCombo, con) elif con.process_path.startswith(constants.SNAP_PREFIX): @@ -570,7 +581,7 @@ def _render_connection(self, con): utils.add_dst_networks_to_combo(self.whatIPCombo, con.dst_ip) self._default_action = self._cfg.getInt(self._cfg.DEFAULT_ACTION_KEY) - utils.set_default_duration(self._cfg, self.durationCombo) + utils.set_default_duration(self._cfg, self.durationCombo, con) utils.set_default_target(self.whatCombo, con, self._cfg, app_name, app_args) diff --git a/ui/opensnitch/dialogs/prompt/utils.py b/ui/opensnitch/dialogs/prompt/utils.py index a362748cf9..9320fd6ae2 100644 --- a/ui/opensnitch/dialogs/prompt/utils.py +++ b/ui/opensnitch/dialogs/prompt/utils.py @@ -177,6 +177,12 @@ def add_snap_pattern_to_combo(combo, con): constants.FIELD_SNAP ) +def add_container_options_to_combo(combo, con): + if "_OS_CONTAINER_ID" in con.process_env: + c_id = con.process_env["_OS_CONTAINER_ID"] + combo.addItem(QC.translate("popups", "from container instance {0}").format(c_id[:12]), constants.FIELD_CGROUP_ID) + if "_OS_CONTAINER_CGROUP_PATH" in con.process_env: + combo.addItem(QC.translate("popups", "from this container cgroup path"), constants.FIELD_CGROUP_PATH) def add_dst_networks_to_combo(combo, dst_ip): alias = NetworkAliases.get_alias(dst_ip) @@ -220,7 +226,12 @@ def get_duration(duration_idx): else: return Config.DURATION_ALWAYS -def set_default_duration(cfg, durationCombo): +def set_default_duration(cfg, durationCombo, con): + if "_OS_CONTAINER_ID" in con.process_env: + # Container connections default to a shorter 15m duration. + durationCombo.setCurrentIndex(3) # 15m + return + if cfg.hasKey(Config.DEFAULT_DURATION_KEY): cur_idx = cfg.getInt(Config.DEFAULT_DURATION_KEY) durationCombo.setCurrentIndex(cur_idx) @@ -241,6 +252,12 @@ def set_default_target(combo, con, cfg, app_name, app_args): combo.setCurrentIndex(idx) return + if "_OS_CONTAINER_ID" in con.process_env: + idx = combo.findData(constants.FIELD_CGROUP_ID) + if idx != -1: + combo.setCurrentIndex(idx) + return + saved_target = int(cfg.getSettings(cfg.DEFAULT_TARGET_KEY)) # In order to respect user selection, the app_name and app_args must be # non-empty. @@ -267,6 +284,12 @@ def get_combo_operator(data, comboText, con): if data == constants.FIELD_PROC_PATH: return Config.RULE_TYPE_SIMPLE, Config.OPERAND_PROCESS_PATH, con.process_path + elif data == constants.FIELD_CGROUP_PATH: + return Config.RULE_TYPE_SIMPLE, Config.OPERAND_CGROUP_PATH, con.process_env.get("_OS_CONTAINER_CGROUP_PATH", "") + + elif data == constants.FIELD_CGROUP_ID: + return Config.RULE_TYPE_SIMPLE, Config.OPERAND_CGROUP_ID, con.process_env.get("_OS_CONTAINER_ID", "") + elif data == constants.FIELD_PROC_ARGS: # this should not happen if len(con.process_args) == 0 or con.process_args[0] == "": diff --git a/ui/tests/dialogs/test_prompt_utils.py b/ui/tests/dialogs/test_prompt_utils.py new file mode 100644 index 0000000000..b35cee4eb7 --- /dev/null +++ b/ui/tests/dialogs/test_prompt_utils.py @@ -0,0 +1,64 @@ +from PyQt6 import QtWidgets + +from opensnitch.config import Config +from opensnitch.dialogs.prompt import constants +from opensnitch.dialogs.prompt import utils + + +class TestPromptUtils: + + def setup_method(self): + Config.init() + + def test_add_container_options_to_combo(self, qtbot): + combo = QtWidgets.QComboBox() + qtbot.addWidget(combo) + + con = type("Conn", (), { + "process_env": { + "_OS_CONTAINER_ID": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "_OS_CONTAINER_CGROUP_PATH": "/system.slice/containerd.service/containerd-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.scope", + } + })() + + utils.add_container_options_to_combo(combo, con) + + assert combo.count() == 2 + assert combo.itemData(0) == constants.FIELD_CGROUP_ID + assert combo.itemData(1) == constants.FIELD_CGROUP_PATH + + def test_set_default_duration_for_container(self, qtbot): + combo = QtWidgets.QComboBox() + qtbot.addWidget(combo) + for label in ["once", "30s", "5m", "15m", "30m", "1h", "12h", "until restart", "always"]: + combo.addItem(label) + + con = type("Conn", (), {"process_env": {"_OS_CONTAINER_ID": "0123456789abcdef"}})() + + utils.set_default_duration(Config.get(), combo, con) + + assert combo.currentIndex() == 3 + + def test_get_combo_operator_for_container_operands(self): + con = type("Conn", (), { + "process_env": { + "_OS_CONTAINER_ID": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "_OS_CONTAINER_CGROUP_PATH": "/system.slice/containerd.service/containerd-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.scope", + }, + "process_path": "/usr/bin/python", + "process_args": ["python", "app.py"], + "process_id": 1234, + "user_id": 1000, + "dst_port": 443, + "dst_ip": "1.2.3.4", + })() + + rule_type, operand, value = utils.get_combo_operator(constants.FIELD_CGROUP_ID, "from container instance", con) + assert rule_type == Config.RULE_TYPE_SIMPLE + assert operand == Config.OPERAND_CGROUP_ID + assert value == con.process_env["_OS_CONTAINER_ID"] + + rule_type, operand, value = utils.get_combo_operator(constants.FIELD_CGROUP_PATH, "from this container cgroup path", con) + assert rule_type == Config.RULE_TYPE_SIMPLE + assert operand == Config.OPERAND_CGROUP_PATH + assert value == con.process_env["_OS_CONTAINER_CGROUP_PATH"] \ No newline at end of file