From 8a6588088a75f200c5c1d4e48f293fd68591c58f Mon Sep 17 00:00:00 2001 From: Yoann Ghigoff Date: Thu, 9 Jan 2025 15:25:54 +0100 Subject: [PATCH] [CWS] Snapshot and runtime use the same file to identify cgroups (#32693) Co-authored-by: lebauce --- pkg/security/ebpf/c/include/hooks/cgroup.h | 9 +- pkg/security/resolvers/container/resolver.go | 19 +- .../resolvers/process/resolver_ebpf.go | 27 +-- pkg/security/resolvers/resolvers_ebpf.go | 10 +- pkg/security/resolvers/resolvers_ebpfless.go | 8 +- pkg/security/tests/cgroup_test.go | 225 +++++++++++++++++- pkg/security/utils/cgroup.go | 116 ++++++++- 7 files changed, 360 insertions(+), 54 deletions(-) diff --git a/pkg/security/ebpf/c/include/hooks/cgroup.h b/pkg/security/ebpf/c/include/hooks/cgroup.h index b7ce66a870b92f..e6856337759a36 100644 --- a/pkg/security/ebpf/c/include/hooks/cgroup.h +++ b/pkg/security/ebpf/c/include/hooks/cgroup.h @@ -110,15 +110,15 @@ static __attribute__((always_inline)) int trace__cgroup_write(ctx_t *ctx) { bpf_probe_read(&f, sizeof(f), &kern_f->file); struct dentry *dentry = get_file_dentry(f); - resolver->key.ino = get_dentry_ino(dentry); - resolver->key.mount_id = get_file_mount_id(f); - resolver->dentry = dentry; - // The last dentry in the cgroup path should be `cgroup.procs`, thus the container ID should be its parent. bpf_probe_read(&container_d, sizeof(container_d), &dentry->d_parent); bpf_probe_read(&container_qstr, sizeof(container_qstr), &container_d->d_name); container_id = (void *)container_qstr.name; + resolver->key.ino = get_dentry_ino(container_d); + resolver->key.mount_id = get_file_mount_id(f); + resolver->dentry = container_d; + if (is_docker_cgroup(ctx, container_d)) { cgroup_flags = CGROUP_MANAGER_DOCKER; } @@ -133,6 +133,7 @@ static __attribute__((always_inline)) int trace__cgroup_write(ctx_t *ctx) { u64 inode = get_dentry_ino(container_d); resolver->key.ino = inode; + struct file_t *entry = bpf_map_lookup_elem(&exec_file_cache, &inode); if (entry == NULL) { return 0; diff --git a/pkg/security/resolvers/container/resolver.go b/pkg/security/resolvers/container/resolver.go index 9176c87e54e8ae..c74c4a88b878a0 100644 --- a/pkg/security/resolvers/container/resolver.go +++ b/pkg/security/resolvers/container/resolver.go @@ -15,10 +15,19 @@ import ( ) // Resolver is used to resolve the container context of the events -type Resolver struct{} +type Resolver struct { + fs *utils.CGroupFS +} + +// New creates a new container resolver +func New() *Resolver { + return &Resolver{ + fs: utils.NewCGroupFS(), + } +} -// GetContainerContext returns the container id of the given pid along with its flags -func (cr *Resolver) GetContainerContext(pid uint32) (containerutils.ContainerID, model.CGroupContext, error) { - // Parse /proc/[pid]/task/[pid]/cgroup - return utils.GetProcContainerContext(pid, pid) +// GetContainerContext returns the container id, cgroup context, and cgroup sysfs path of the given pid +func (cr *Resolver) GetContainerContext(pid uint32) (containerutils.ContainerID, model.CGroupContext, string, error) { + // Parse /proc/[pid]/task/[pid]/cgroup and /sys/fs/cgroup/[cgroup] + return cr.fs.FindCGroupContext(pid, pid) } diff --git a/pkg/security/resolvers/process/resolver_ebpf.go b/pkg/security/resolvers/process/resolver_ebpf.go index 597487bbae944a..638c03b51b791a 100644 --- a/pkg/security/resolvers/process/resolver_ebpf.go +++ b/pkg/security/resolvers/process/resolver_ebpf.go @@ -375,12 +375,22 @@ func (p *EBPFResolver) enrichEventFromProc(entry *model.ProcessCacheEntry, proc return fmt.Errorf("snapshot failed for %d: couldn't retrieve inode info: %w", proc.Pid, err) } - // Retrieve the container ID of the process from /proc - containerID, cgroup, err := p.containerResolver.GetContainerContext(pid) + // Retrieve the container ID of the process from /proc and /sys/fs/cgroup/[cgroup] + containerID, cgroup, cgroupSysFSPath, err := p.containerResolver.GetContainerContext(pid) if err != nil { return fmt.Errorf("snapshot failed for %d: couldn't parse container and cgroup context: %w", proc.Pid, err) } + if cgroup.CGroupFile.Inode != 0 && cgroup.CGroupFile.MountID == 0 { // the mount id is unavailable through statx + // Get the file fields of the sysfs cgroup file + info, err := p.retrieveExecFileFields(cgroupSysFSPath) + if err != nil && !errors.Is(err, lib.ErrKeyNotExist) { + seclog.Debugf("snapshot failed for %d: couldn't retrieve inode info: %s", proc.Pid, err) + } else { + cgroup.CGroupFile.MountID = info.MountID + } + } + entry.ContainerID = containerID entry.CGroup = cgroup @@ -393,17 +403,6 @@ func (p *EBPFResolver) enrichEventFromProc(entry *model.ProcessCacheEntry, proc entry.FileEvent.MountOrigin = model.MountOriginProcfs entry.FileEvent.MountSource = model.MountSourceSnapshot - if entry.Process.CGroup.CGroupFile.MountID == 0 { - // Get the file fields of the cgroup file - taskPath := utils.CgroupTaskPath(pid, pid) - info, err := p.retrieveExecFileFields(taskPath) - if err != nil { - seclog.Debugf("snapshot failed for %d: couldn't retrieve inode info: %s", proc.Pid, err) - } else { - entry.Process.CGroup.CGroupFile.MountID = info.MountID - } - } - if entry.FileEvent.IsFileless() { entry.FileEvent.Filesystem = model.TmpFS } else { @@ -892,7 +891,7 @@ func (p *EBPFResolver) resolveFromKernelMaps(pid, tid uint32, inode uint64, newE // the parent is in a container. In other words, we have to fall back to /proc to query the container ID of the // process. if entry.CGroup.CGroupFile.Inode == 0 { - if containerID, cgroup, err := p.containerResolver.GetContainerContext(pid); err == nil { + if containerID, cgroup, _, err := p.containerResolver.GetContainerContext(pid); err == nil { entry.CGroup.Merge(&cgroup) entry.ContainerID = containerID } diff --git a/pkg/security/resolvers/resolvers_ebpf.go b/pkg/security/resolvers/resolvers_ebpf.go index 99ab1e6eb2c8ae..2c0af680785780 100644 --- a/pkg/security/resolvers/resolvers_ebpf.go +++ b/pkg/security/resolvers/resolvers_ebpf.go @@ -12,7 +12,6 @@ import ( "context" "fmt" "os" - "path/filepath" "sort" "github.com/DataDog/datadog-go/v5/statsd" @@ -133,7 +132,7 @@ func NewEBPFResolvers(config *config.Config, manager *manager.Manager, statsdCli mountResolver = &mount.NoOpResolver{} pathResolver = &path.NoOpResolver{} } - containerResolver := &container.Resolver{} + containerResolver := container.New() processOpts := process.NewResolverOpts() processOpts.WithEnvsValue(config.Probe.EnvsWithValue) @@ -223,16 +222,11 @@ func (r *EBPFResolvers) ResolveCGroupContext(pathKey model.PathKey, cgroupFlags return cgroupContext, nil } - path, err := r.DentryResolver.Resolve(pathKey, true) + cgroup, err := r.DentryResolver.Resolve(pathKey, true) if err != nil { return nil, fmt.Errorf("failed to resolve cgroup file %v: %w", pathKey, err) } - cgroup := filepath.Dir(string(path)) - if cgroup == "/" { - cgroup = path - } - cgroupContext := &model.CGroupContext{ CGroupID: containerutils.CGroupID(cgroup), CGroupFlags: containerutils.CGroupFlags(cgroupFlags), diff --git a/pkg/security/resolvers/resolvers_ebpfless.go b/pkg/security/resolvers/resolvers_ebpfless.go index 8eb7cf42cca443..2720f5be15a864 100644 --- a/pkg/security/resolvers/resolvers_ebpfless.go +++ b/pkg/security/resolvers/resolvers_ebpfless.go @@ -16,7 +16,6 @@ import ( "github.com/DataDog/datadog-agent/pkg/process/procutil" "github.com/DataDog/datadog-agent/pkg/security/config" "github.com/DataDog/datadog-agent/pkg/security/resolvers/cgroup" - "github.com/DataDog/datadog-agent/pkg/security/resolvers/container" "github.com/DataDog/datadog-agent/pkg/security/resolvers/hash" "github.com/DataDog/datadog-agent/pkg/security/resolvers/process" "github.com/DataDog/datadog-agent/pkg/security/resolvers/tags" @@ -24,10 +23,9 @@ import ( // EBPFLessResolvers holds the list of the event attribute resolvers type EBPFLessResolvers struct { - ContainerResolver *container.Resolver - TagsResolver *tags.LinuxResolver - ProcessResolver *process.EBPFLessResolver - HashResolver *hash.Resolver + TagsResolver *tags.LinuxResolver + ProcessResolver *process.EBPFLessResolver + HashResolver *hash.Resolver } // NewEBPFLessResolvers creates a new instance of EBPFLessResolvers diff --git a/pkg/security/tests/cgroup_test.go b/pkg/security/tests/cgroup_test.go index 5259c04583f494..d5ca5b5108f6ee 100644 --- a/pkg/security/tests/cgroup_test.go +++ b/pkg/security/tests/cgroup_test.go @@ -12,29 +12,82 @@ import ( "fmt" "os" "os/exec" + "slices" "strconv" "syscall" "testing" "github.com/stretchr/testify/assert" + "golang.org/x/sys/unix" "github.com/DataDog/datadog-agent/pkg/security/ebpf/kernel" + "github.com/DataDog/datadog-agent/pkg/security/probe" "github.com/DataDog/datadog-agent/pkg/security/secl/containerutils" "github.com/DataDog/datadog-agent/pkg/security/secl/model" "github.com/DataDog/datadog-agent/pkg/security/secl/rules" + "github.com/DataDog/datadog-agent/pkg/security/utils" ) -func createCGroup(name string) (string, error) { - cgroupPath := "/sys/fs/cgroup/memory/" + name - if err := os.MkdirAll(cgroupPath, 0700); err != nil { - return "", err +type testCGroup struct { + cgroupPath string + previousCGroupPath string +} + +func (cg *testCGroup) enter() error { + return os.WriteFile(cg.cgroupPath+"/cgroup.procs", []byte(strconv.Itoa(os.Getpid())), 0700) +} + +func (cg *testCGroup) leave(t *testing.T) { + if err := os.WriteFile("/sys/fs/cgroup"+cg.previousCGroupPath+"/cgroup.procs", []byte(strconv.Itoa(os.Getpid())), 0700); err != nil { + if err := os.WriteFile("/sys/fs/cgroup/systemd"+cg.previousCGroupPath+"/cgroup.procs", []byte(strconv.Itoa(os.Getpid())), 0700); err != nil { + t.Log(err) + return + } + } +} + +func (cg *testCGroup) remove(t *testing.T) { + if err := os.Remove(cg.cgroupPath); err != nil { + if content, err := os.ReadFile(cg.cgroupPath + "/cgroup.procs"); err == nil { + t.Logf("Processes in cgroup: %s", string(content)) + } + } +} + +func (cg *testCGroup) create() error { + return os.MkdirAll(cg.cgroupPath, 0700) +} + +func newCGroup(name, kind string) (*testCGroup, error) { + cgs, err := utils.GetProcControlGroups(uint32(os.Getpid()), uint32(os.Getpid())) + if err != nil { + return nil, err + } + + var previousCGroupPath string + for _, cg := range cgs { + if len(cg.Controllers) == 1 && cg.Controllers[0] == "" { + previousCGroupPath = cg.Path + break + } + if previousCGroupPath == "" { + previousCGroupPath = cg.Path + } else if previousCGroupPath == "/" { + previousCGroupPath = cg.Path + } + if slices.Contains(cg.Controllers, kind) || slices.Contains(cg.Controllers, "name="+kind) { + previousCGroupPath = cg.Path + break + } } - if err := os.WriteFile(cgroupPath+"/cgroup.procs", []byte(strconv.Itoa(os.Getpid())), 0700); err != nil { - return "", err + cgroupPath := "/sys/fs/cgroup/" + kind + "/" + name + cg := &testCGroup{ + previousCGroupPath: previousCGroupPath, + cgroupPath: cgroupPath, } - return cgroupPath, nil + return cg, nil } func TestCGroup(t *testing.T) { @@ -47,7 +100,7 @@ func TestCGroup(t *testing.T) { ruleDefs := []*rules.RuleDefinition{ { ID: "test_cgroup_id", - Expression: `open.file.path == "{{.Root}}/test-open" && cgroup.id =~ "*/cg1"`, // "/memory/cg1" or "/cg1" + Expression: `open.file.path == "{{.Root}}/test-open" && cgroup.id =~ "*/cg1"`, // "/cpu/cg1" or "/cg1" }, { ID: "test_cgroup_systemd", @@ -60,11 +113,20 @@ func TestCGroup(t *testing.T) { } defer test.Close() - cgroupPath, err := createCGroup("cg1") + testCGroup, err := newCGroup("cg1", "cpu") if err != nil { t.Fatal(err) } - defer os.RemoveAll(cgroupPath) + + if err := testCGroup.create(); err != nil { + t.Fatal(err) + } + defer testCGroup.remove(t) + + if err := testCGroup.enter(); err != nil { + t.Fatal(err) + } + defer testCGroup.leave(t) testFile, testFilePtr, err := test.Path("test-open") if err != nil { @@ -90,7 +152,7 @@ func TestCGroup(t *testing.T) { assertFieldEqual(t, event, "container.id", "") assertFieldEqual(t, event, "container.runtime", "") assert.Equal(t, containerutils.CGroupFlags(0), event.CGroupContext.CGroupFlags) - assertFieldIsOneOf(t, event, "cgroup.id", "/memory/cg1") + assertFieldIsOneOf(t, event, "cgroup.id", "/cpu/cg1") assertFieldIsOneOf(t, event, "cgroup.version", []int{1, 2}) test.validateOpenSchema(t, event) @@ -176,3 +238,144 @@ ExecStart=/usr/bin/touch %s`, testFile2) }) }) } + +func TestCGroupSnapshot(t *testing.T) { + if testEnvironment == DockerEnvironment { + t.Skip("skipping cgroup ID test in docker") + } + + SkipIfNotAvailable(t) + + _, cgroupContext, err := utils.GetProcContainerContext(uint32(os.Getpid()), uint32(os.Getpid())) + if err != nil { + t.Fatal(err) + } + + testCGroup, err := newCGroup("cg2", "systemd") + if err != nil { + t.Fatal(err) + } + + if err := testCGroup.create(); err != nil { + t.Fatal(err) + } + defer testCGroup.remove(t) + + if err := testCGroup.enter(); err != nil { + t.Fatal(err) + } + defer testCGroup.leave(t) + + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + + var testsuiteStats unix.Stat_t + if err := unix.Stat(executable, &testsuiteStats); err != nil { + t.Fatal(err) + } + + ruleDefs := []*rules.RuleDefinition{ + { + ID: "test_cgroup_snapshot", + Expression: `open.file.path == "{{.Root}}/test-open" && cgroup.id != ""`, + }, + } + + test, err := newTestModule(t, nil, ruleDefs) + if err != nil { + t.Fatal(err) + } + defer test.Close() + + testFile, _, err := test.Path("test-open") + if err != nil { + t.Fatal(err) + } + + syscallTester, err := loadSyscallTester(t, test, "syscall_tester") + if err != nil { + t.Fatal(err) + } + + var syscallTesterStats unix.Stat_t + if err := unix.Stat(syscallTester, &syscallTesterStats); err != nil { + t.Fatal(err) + } + + p, ok := test.probe.PlatformProbe.(*probe.EBPFProbe) + if !ok { + t.Skip("not supported") + } + + var cmd *exec.Cmd + test.WaitSignal(t, func() error { + cmd = exec.Command(syscallTester, "open", testFile) + pipe, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + defer pipe.Close() + + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + return nil + }, func(event *model.Event, rule *rules.Rule) { + assertTriggeredRule(t, rule, "test_cgroup_snapshot") + test.validateOpenSchema(t, event) + + testsuiteEntry := p.Resolvers.ProcessResolver.Get(uint32(os.Getpid())) + syscallTesterEntry := p.Resolvers.ProcessResolver.Get(uint32(cmd.Process.Pid)) + assert.NotNil(t, testsuiteEntry) + assert.NotNil(t, syscallTesterEntry) + + // Check that testsuite has changed cgroup since its start + assert.NotEqual(t, cgroupContext.CGroupID, testsuiteEntry.CGroup.CGroupID) + assert.Equal(t, int(testsuiteEntry.Pid), os.Getpid()) + + // Check that both testsuite and syscall tester share the same cgroup + assert.Equal(t, testsuiteEntry.CGroup.CGroupID, syscallTesterEntry.CGroup.CGroupID) + assert.Equal(t, testsuiteEntry.CGroup.CGroupFile, syscallTesterEntry.CGroup.CGroupFile) + + // Check that we have the right cgroup inode + cgroupFS := utils.NewCGroupFS() + _, _, cgroupSysFSPath, err := cgroupFS.FindCGroupContext(uint32(os.Getpid()), uint32(os.Getpid())) + if err != nil { + t.Fatal(err) + } + + var stats unix.Stat_t + if err := unix.Stat(cgroupSysFSPath, &stats); err != nil { + t.Fatal(err) + } + assert.Equal(t, stats.Ino, testsuiteEntry.CGroup.CGroupFile.Inode) + + // Check we filled the kernel maps correctly with the same values than userspace for the testsuite process + var newEntry *model.ProcessCacheEntry + ebpfProbe := test.probe.PlatformProbe.(*probe.EBPFProbe) + ebpfProbe.Resolvers.ProcessResolver.ResolveFromKernelMaps(uint32(os.Getpid()), uint32(os.Getpid()), testsuiteStats.Ino, func(entry *model.ProcessCacheEntry, _ error) { + newEntry = entry + }) + assert.NotNil(t, newEntry) + if newEntry != nil { + assert.Equal(t, stats.Ino, newEntry.CGroup.CGroupFile.Inode) + } + + // Check we filled the kernel maps correctly with the same values than userspace for the syscall tester process + newEntry = nil + ebpfProbe.Resolvers.ProcessResolver.ResolveFromKernelMaps(syscallTesterEntry.Pid, syscallTesterEntry.Pid, syscallTesterStats.Ino, func(entry *model.ProcessCacheEntry, _ error) { + newEntry = entry + }) + assert.NotNil(t, newEntry) + if newEntry != nil { + assert.Equal(t, stats.Ino, newEntry.CGroup.CGroupFile.Inode) + } + }) + + if cmd != nil { + cmd.Process.Kill() + } +} diff --git a/pkg/security/utils/cgroup.go b/pkg/security/utils/cgroup.go index 0c41657b9a3d98..eb1fb5d90a0dca 100644 --- a/pkg/security/utils/cgroup.go +++ b/pkg/security/utils/cgroup.go @@ -12,14 +12,20 @@ import ( "bufio" "bytes" "crypto/sha256" + "errors" "fmt" "os" + "path/filepath" "strconv" "strings" + "github.com/moby/sys/mountinfo" + + "golang.org/x/sys/unix" + "github.com/DataDog/datadog-agent/pkg/security/secl/containerutils" "github.com/DataDog/datadog-agent/pkg/security/secl/model" - "golang.org/x/sys/unix" + "github.com/DataDog/datadog-agent/pkg/util/kernel" ) // ContainerIDLen is the length of a container ID is the length of the hex representation of a sha256 hash @@ -44,12 +50,6 @@ func (cg ControlGroup) GetContainerContext() (containerutils.ContainerID, contai return containerutils.ContainerID(id), containerutils.CGroupFlags(flags) } -// GetContainerID returns the container id extracted from the path of the control group -func (cg ControlGroup) GetContainerID() containerutils.ContainerID { - id, _ := containerutils.FindContainerID(containerutils.CGroupID(cg.Path)) - return containerutils.ContainerID(id) -} - func parseCgroupLine(line string) (string, string, string, error) { id, rest, ok := strings.Cut(line, ":") if !ok { @@ -191,3 +191,105 @@ func GetProcContainerContext(tgid, pid uint32) (containerutils.ContainerID, mode return containerID, cgroupContext, nil } + +var defaultCGroupMountpoints = []string{ + "/sys/fs/cgroup", + "/sys/fs/cgroup/unified", +} + +// ErrNoCGroupMountpoint is returned when no cgroup mount point is found +var ErrNoCGroupMountpoint = errors.New("no cgroup mount point found") + +// CGroupFS is a helper type used to find the cgroup context of a process +type CGroupFS struct { + cGroupMountPoints []string +} + +// NewCGroupFS creates a new CGroupFS instance +func NewCGroupFS(cgroupMountPoints ...string) *CGroupFS { + cfs := &CGroupFS{} + + var cgroupMnts []string + if len(cgroupMountPoints) == 0 { + cgroupMnts = defaultCGroupMountpoints + } else { + cgroupMnts = cgroupMountPoints + } + + for _, mountpoint := range cgroupMnts { + hostMountpoint := filepath.Join(kernel.SysFSRoot(), strings.TrimPrefix(mountpoint, "/sys/")) + if mounted, _ := mountinfo.Mounted(hostMountpoint); mounted { + cfs.cGroupMountPoints = append(cfs.cGroupMountPoints, hostMountpoint) + } + } + + return cfs +} + +// FindCGroupContext returns the container ID, cgroup context and sysfs cgroup path the process belongs to. +// Returns "" as container ID and sysfs cgroup path, and an empty CGroupContext if the process does not belong to a container. +func (cfs *CGroupFS) FindCGroupContext(tgid, pid uint32) (containerutils.ContainerID, model.CGroupContext, string, error) { + if len(cfs.cGroupMountPoints) == 0 { + return "", model.CGroupContext{}, "", ErrNoCGroupMountpoint + } + + var ( + containerID containerutils.ContainerID + cgroupContext model.CGroupContext + sysFScGroupPath string + ) + + err := parseProcControlGroups(tgid, pid, func(_, ctrl, path string) bool { + if path == "/" { + return false + } else if ctrl != "" && !strings.HasPrefix(ctrl, "name=") { + // On cgroup v1 we choose to take the "name" ctrl entry (ID 1), as the ID 0 could be empty + // On cgroup v2, it's only a single line with ID 0 and no ctrl + // (Cf unit tests for examples) + return false + } + + ctrlDirectory := strings.TrimPrefix(ctrl, "name=") + for _, mountpoint := range cfs.cGroupMountPoints { + cgroupPath := filepath.Join(mountpoint, ctrlDirectory, path) + if exists, err := checkPidExists(cgroupPath, pid); err == nil && exists { + cgroupID := containerutils.CGroupID(path) + ctrID, flags := containerutils.FindContainerID(cgroupID) + cgroupContext.CGroupID = cgroupID + cgroupContext.CGroupFlags = containerutils.CGroupFlags(flags) + containerID = ctrID + sysFScGroupPath = cgroupPath + + var fileStatx unix.Statx_t + var fileStats unix.Stat_t + if err := unix.Statx(unix.AT_FDCWD, sysFScGroupPath, 0, unix.STATX_INO|unix.STATX_MNT_ID, &fileStatx); err == nil { + cgroupContext.CGroupFile.MountID = uint32(fileStatx.Mnt_id) + cgroupContext.CGroupFile.Inode = fileStatx.Ino + } else if err := unix.Stat(sysFScGroupPath, &fileStats); err == nil { + cgroupContext.CGroupFile.Inode = fileStats.Ino + } + return true + } + } + return false + }) + if err != nil { + return "", model.CGroupContext{}, "", err + } + + return containerID, cgroupContext, sysFScGroupPath, nil +} + +func checkPidExists(sysFScGroupPath string, expectedPid uint32) (bool, error) { + data, err := os.ReadFile(filepath.Join(sysFScGroupPath, "cgroup.procs")) + if err != nil { + return false, err + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + if pid, err := strconv.Atoi(strings.TrimSpace(scanner.Text())); err == nil && uint32(pid) == expectedPid { + return true, nil + } + } + return false, nil +}